gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit139fafd8139fafd8tracker#16: short bio (4 lines, click = all), real-data check script, README + STATUSmre139fafd8/tools/migrate.hl

18.0 KB

  1. // tools/migrate.hl — ONE-OFF MIGRATION (tracker.worldapi.org#2): the old MongoDB export
  2. // (/media/STORAGE/projects/old-tracker/mongo-export/*.jsonl, EJSON, one document per line)
  3. // into this app's own mpackdb tables (storage/mpackdb/). EVERY record gets a NEW mpackdb id
  4. // (self-assigned below — see "why self-assigned ids" — not mpackdb's auto-generated one); the
  5. // old Mongo hex id is kept as `oldId` (a unique index: the idempotency key — a second run
  6. // finds the same id again via `oldId` and skips the row, printing "already there").
  7. //
  8. // genres.db { name, tmdbId, urlSegment, oldId }
  9. // persons.db { name, tmdbId, gender, urlSegment, birthDate, birthPlace, deathDate, image,
  10. // imdbId, shows [{ show, character }], oldId }
  11. // shows.db { title, summary, tmdbSummary, tmdbId, language, type, release, year, image,
  12. // urlSegment, episodesCount, homepage, imdbId, seasonsCount, status, tagline,
  13. // tvdbId, tvmzId, genres [genre id], cast [{ person, character, actor }],
  14. // seasons [season id], oldId }
  15. // seasons.db { seasonNumber, release, title, summary, show, episodes [episode id], oldId }
  16. // episodes.db { tmdbId, episodeNumber, seasonNumber, title, summary, release, show, season,
  17. // oldId } -- season is null where the OLD record's own season link was
  18. // already null or dangling (pre-existing in the export, not introduced here)
  19. // follows.db { show, user, at, oldId }
  20. // watches.db { targetType ('episode'|'season'), target, user, at, oldId } -- target is
  21. // null for the handful of watches whose old target no longer exists in the
  22. // export (pre-existing dangling refs; kept so counts still match)
  23. //
  24. // The single old user (User.jsonl, [email protected], password NOT taken over) becomes
  25. // this app's own user for ident short id az5b2 (users.hl `ensureUser`, storage/mpackdb/users.db
  26. // — the SAME table step 1's login already uses).
  27. //
  28. // WHY SELF-ASSIGNED IDS, NO update(): Show.seasons/Season.show and Show.cast/Person.shows are
  29. // each a genuine two-way reference (a show needs its seasons' ids; a season needs its show's id
  30. // — same for shows and persons via cast). The first version of this tool broke that circle the
  31. // obvious way — put() a bare record, come back later with fetch()+update() once the other side's
  32. // ids exist. That LOSES ROWS: reopening a table in a fresh process after a run that had done
  33. // update() on it read back fewer rows than were put (seen directly: count() dropped from 15157 to
  34. // 15152 after persons.db round-tripped through update() once — a genuine hl:mpackdb bug, filed as
  35. // a Hybriel gap, tracker.worldapi.org#2 — not something a vendored-plugin patch is allowed to fix
  36. // here). Tables that only ever got put() never lost a row in the same testing.
  37. // The fix: assign every new id ourselves (`resolveId`, 8 random bytes as hex, hl:crypto) in one
  38. // pass over each file BEFORE building any record, so by the time a record is actually built every
  39. // side of every reference is already a known id — one put() per row, with its id and every
  40. // reference already resolved. mpackdb's own auto id is never used; hybriel#23 already establishes
  41. // that put() keeps an id the record carries, which this leans on deliberately.
  42. //
  43. // Run (absolute paths; relative ones resolve against tools/, not the project root):
  44. // TRACKER_OLD_DATA=/media/STORAGE/projects/old-tracker/mongo-export \
  45. // TRACKER_STORAGE=$PWD/storage/mpackdb \
  46. // TRACKER_MIGRATE_TMP=$PWD/.scratch/migrate-tmp \
  47. // ./bin/hybriel tools/migrate.hl
  48. import { MPackDB } from 'hl:mpackdb'
  49. import { env, run } from 'hl:proc'
  50. import { readFile, mkDir, exists } from 'hl:fs'
  51. import { randomBytes } from 'hl:crypto'
  52. import { ensureUser } from '../users.hl'
  53. static nl = "
  54. "
  55. oldDir = env('TRACKER_OLD_DATA')
  56. storageDir = env('TRACKER_STORAGE')
  57. tmpDir = env('TRACKER_MIGRATE_TMP')
  58. if (oldDir == null || !oldDir.startsWith('/') || storageDir == null || !storageDir.startsWith('/') || tmpDir == null || !tmpDir.startsWith('/')) {
  59. hlError('set TRACKER_OLD_DATA (the mongo-export directory), TRACKER_STORAGE (the mpackdb directory) and TRACKER_MIGRATE_TMP (a scratch directory for the chunk workaround below), all ABSOLUTE')
  60. }
  61. // ---- reading the export --------------------------------------------------------------
  62. static linesOf = (path) => {
  63. let raw = readFile(path)
  64. if (raw == null) { return [] }
  65. let out = []
  66. for (l of raw.split(nl)) { if (l != '') { out.push(l) } }
  67. return out
  68. }
  69. static oidOf = (v) => {
  70. if (v == null) { return null }
  71. if (hlTypeName(v) == 'String') { return v }
  72. return v['$oid']
  73. }
  74. static numOf = (v) => {
  75. if (v == null) { return null }
  76. if (hlTypeName(v) == 'Number') { return v }
  77. let a = v['$numberInt']
  78. if (a == null) { a = v['$numberLong'] }
  79. return a == null ? null : toNumber(a)
  80. }
  81. static dateOf = (v) => {
  82. if (v == null) { return null }
  83. let d = v['$date']
  84. return d == null ? null : numOf(d)
  85. }
  86. // a JSON list field that may be absent or explicitly null (most Show.cast/.seasons and
  87. // nearly all Person.shows are) — iterating `null` with for-of aborts the whole run
  88. static listOr = (v) => { return v == null ? [] : v }
  89. // hl:fs readFile SILENTLY TRUNCATES at 10 MiB (fs.zig hl_fs_read_file, max_size = 1024*1024*10,
  90. // no error, no flag — reported as a Hybriel gap, tracker.worldapi.org#2): Show.jsonl (12.4 MB),
  91. // Season.jsonl (10.9 MB) and Episode.jsonl (140 MB) are all over it. Worked around here WITHOUT
  92. // touching the vendored plugin: `split` (coreutils, via hl:proc run — no shell, no JS) cuts the
  93. // export into <10 MB chunks in a scratch dir first; each chunk is read whole (well under the cap).
  94. static padNum4 = (n) => {
  95. let s = '' + n
  96. while (s.length < 4) { s = '0' + s }
  97. return s
  98. }
  99. static bigLinesOf = (path, chunkDir, perChunk) => {
  100. mkDir(chunkDir, 448)
  101. let r = run(['split', '-l', '' + perChunk, '-d', '-a', '4', '--additional-suffix=.jsonl', path, chunkDir + '/c_'])
  102. if (r == null || r.exit != 0) { hlError('split failed for ' + path) }
  103. let out = []
  104. let idx = 0
  105. while (true) {
  106. let fp = chunkDir + '/c_' + padNum4(idx) + '.jsonl'
  107. if (!exists(fp)) { break }
  108. for (l of linesOf(fp)) { out.push(l) }
  109. idx = idx + 1
  110. }
  111. if (idx == 0) { hlError('no chunks produced for ' + path) }
  112. return out
  113. }
  114. // ---- tables ---------------------------------------------------------------------------
  115. genresTable = new MPackDB(file = storageDir + '/genres.db', primaryKey = '@id', indexes = ['!oldId'])
  116. personsTable = new MPackDB(file = storageDir + '/persons.db', primaryKey = '@id', indexes = ['!oldId'])
  117. showsTable = new MPackDB(file = storageDir + '/shows.db', primaryKey = '@id', indexes = ['!oldId'])
  118. seasonsTable = new MPackDB(file = storageDir + '/seasons.db', primaryKey = '@id', indexes = ['!oldId'])
  119. episodesTable = new MPackDB(file = storageDir + '/episodes.db', primaryKey = '@id', indexes = ['!oldId'])
  120. followsTable = new MPackDB(file = storageDir + '/follows.db', primaryKey = '@id', indexes = ['!oldId'])
  121. watchesTable = new MPackDB(file = storageDir + '/watches.db', primaryKey = '@id', indexes = ['!oldId'])
  122. // the new id for an old row: reuse what a previous run already gave it (a point lookup on the
  123. // unique `oldId` index — proven reliable; it's a FULL table scan, find(null, null), that lost
  124. // rows together with update() in testing, so this never calls that), or a fresh random id
  125. static newId = () => { return randomBytes(8, 'hex') }
  126. static existingId = (table, oldId) => {
  127. let found = table.find('oldId', oldId)
  128. if (found != null && found.length > 0) { return found[0].id }
  129. return null
  130. }
  131. static resolveId = (table, oldId) => {
  132. let ex = existingId(table, oldId)
  133. return ex == null ? newId() : ex
  134. }
  135. report = []
  136. failed = 0
  137. // ---- read every file once (chunked for the big three) --------------------------------
  138. genreLines = linesOf(oldDir + '/Genre.jsonl')
  139. personLines = linesOf(oldDir + '/Person.jsonl')
  140. showLines = bigLinesOf(oldDir + '/Show.jsonl', tmpDir + '/show', 5000)
  141. seasonLines = bigLinesOf(oldDir + '/Season.jsonl', tmpDir + '/season', 3000)
  142. episodeLines = bigLinesOf(oldDir + '/Episode.jsonl', tmpDir + '/episode', 5000)
  143. followLines = linesOf(oldDir + '/Follow.jsonl')
  144. watchLines = linesOf(oldDir + '/Watch.jsonl')
  145. // ---- phase 1: id maps only (every old id -> its new id, self-assigned) ---------------
  146. // every reference below (Show.genres/cast/seasons, Season.show/episodes, Episode.show/season,
  147. // Person.shows, Follow.oid, Watch.oid) is resolved against one of these five maps, ALL of
  148. // which are complete before phase 2 builds a single record — that is what removes the need
  149. // for update() (see the file header).
  150. genreMap = {}
  151. for (l of genreLines) { let d = JSON.parse(l) let oldId = oidOf(d._id) genreMap[oldId] = resolveId(genresTable, oldId) }
  152. personMap = {}
  153. for (l of personLines) { let d = JSON.parse(l) let oldId = oidOf(d._id) personMap[oldId] = resolveId(personsTable, oldId) }
  154. showMap = {}
  155. for (l of showLines) { let d = JSON.parse(l) let oldId = oidOf(d._id) showMap[oldId] = resolveId(showsTable, oldId) }
  156. seasonMap = {}
  157. for (l of seasonLines) { let d = JSON.parse(l) let oldId = oidOf(d._id) seasonMap[oldId] = resolveId(seasonsTable, oldId) }
  158. episodeMap = {}
  159. for (l of episodeLines) { let d = JSON.parse(l) let oldId = oidOf(d._id) episodeMap[oldId] = resolveId(episodesTable, oldId) }
  160. // ---- phase 2: build and put the real records (one put() each, every reference resolved) ----
  161. // ---- Genre ----------------------------------------------------------------------------
  162. genreFresh = 0
  163. genreKnown = 0
  164. for (l of genreLines) {
  165. let d = JSON.parse(l)
  166. let oldId = oidOf(d._id)
  167. let id = genreMap[oldId]
  168. if (genresTable.fetch(id) != null) {
  169. genreKnown = genreKnown + 1
  170. } else {
  171. let putId = genresTable.put({ id = id name = d.name tmdbId = numOf(d.tmdbId) urlSegment = d.urlSegment oldId = oldId })
  172. if (putId == null) { console.log('FAIL genre ' + oldId + ': ' + genresTable.lastError()) failed = failed + 1 } else { genreFresh = genreFresh + 1 }
  173. }
  174. }
  175. report.push('genres ' + genreFresh + ' new, ' + genreKnown + ' already there (old ' + genreLines.length + ' -> new ' + genresTable.count() + ')')
  176. // ---- Person (shows resolved against showMap, already complete) -----------------------
  177. personFresh = 0
  178. personKnown = 0
  179. personShowsMissing = 0
  180. for (l of personLines) {
  181. let d = JSON.parse(l)
  182. let oldId = oidOf(d._id)
  183. let id = personMap[oldId]
  184. if (personsTable.fetch(id) != null) {
  185. personKnown = personKnown + 1
  186. } else {
  187. let shows = []
  188. for (s of listOr(d.shows)) {
  189. let shid = showMap[oidOf(s._id)]
  190. if (shid == null) { personShowsMissing = personShowsMissing + 1 } else { shows.push({ show = shid character = s.character }) }
  191. }
  192. let putId = personsTable.put({
  193. id = id name = d.name tmdbId = numOf(d.tmdbId) gender = numOf(d.gender) urlSegment = d.urlSegment
  194. birthDate = d.birthDate birthPlace = d.birthPlace deathDate = d.deathDate image = d.image
  195. imdbId = d.imdbId shows = shows oldId = oldId
  196. })
  197. if (putId == null) { console.log('FAIL person ' + oldId + ': ' + personsTable.lastError()) failed = failed + 1 } else { personFresh = personFresh + 1 }
  198. }
  199. }
  200. report.push('persons ' + personFresh + ' new, ' + personKnown + ' already there (old ' + personLines.length + ' -> new ' + personsTable.count() + '), show refs skipped ' + personShowsMissing)
  201. // ---- Show (genres/cast/seasons resolved against genreMap/personMap/seasonMap) --------
  202. showFresh = 0
  203. showKnown = 0
  204. castMissing = 0
  205. showGenresMissing = 0
  206. showSeasonsMissing = 0
  207. for (l of showLines) {
  208. let d = JSON.parse(l)
  209. let oldId = oidOf(d._id)
  210. let id = showMap[oldId]
  211. if (showsTable.fetch(id) != null) {
  212. showKnown = showKnown + 1
  213. } else {
  214. let genres = []
  215. for (g of listOr(d.genres)) {
  216. let gid = genreMap[oidOf(g._id)]
  217. if (gid == null) { showGenresMissing = showGenresMissing + 1 } else { genres.push(gid) }
  218. }
  219. let cast = []
  220. for (c of listOr(d.cast)) {
  221. let pid = personMap[oidOf(c._id)]
  222. if (pid == null) { castMissing = castMissing + 1 } else { cast.push({ person = pid character = c.character actor = c.actor }) }
  223. }
  224. let seasons = []
  225. for (s of listOr(d.seasons)) {
  226. let sid = seasonMap[oidOf(s)]
  227. if (sid == null) { showSeasonsMissing = showSeasonsMissing + 1 } else { seasons.push(sid) }
  228. }
  229. let putId = showsTable.put({
  230. id = id title = d.title summary = d.summary tmdbSummary = d.tmdbSummary tmdbId = numOf(d.tmdbId)
  231. language = d.language type = d.type release = d.release year = numOf(d.year) image = d.image
  232. urlSegment = d.urlSegment episodesCount = numOf(d.episodesCount) homepage = d.homepage
  233. imdbId = d.imdbId seasonsCount = numOf(d.seasonsCount) status = d.status tagline = d.tagline
  234. tvdbId = numOf(d.tvdbId) tvmzId = numOf(d.tvmzId) genres = genres cast = cast seasons = seasons
  235. oldId = oldId
  236. })
  237. if (putId == null) { console.log('FAIL show ' + oldId + ': ' + showsTable.lastError()) failed = failed + 1 } else { showFresh = showFresh + 1 }
  238. }
  239. }
  240. report.push('shows ' + showFresh + ' new, ' + showKnown + ' already there (old ' + showLines.length + ' -> new ' + showsTable.count() + '), cast refs skipped ' + castMissing + ', genre refs skipped ' + showGenresMissing + ', season refs skipped ' + showSeasonsMissing)
  241. // ---- Season (show resolved against showMap, episodes against episodeMap) -------------
  242. seasonFresh = 0
  243. seasonKnown = 0
  244. seasonShowMissing = 0
  245. seasonEpisodesMissing = 0
  246. for (l of seasonLines) {
  247. let d = JSON.parse(l)
  248. let oldId = oidOf(d._id)
  249. let id = seasonMap[oldId]
  250. if (seasonsTable.fetch(id) != null) {
  251. seasonKnown = seasonKnown + 1
  252. } else {
  253. let show = showMap[oidOf(d.show._id)]
  254. if (show == null) { seasonShowMissing = seasonShowMissing + 1 }
  255. let episodes = []
  256. for (e of listOr(d.episodes)) {
  257. let eid = episodeMap[oidOf(e)]
  258. if (eid == null) { seasonEpisodesMissing = seasonEpisodesMissing + 1 } else { episodes.push(eid) }
  259. }
  260. let putId = seasonsTable.put({ id = id seasonNumber = numOf(d.seasonNumber) release = d.release title = d.title summary = d.summary show = show episodes = episodes oldId = oldId })
  261. if (putId == null) { console.log('FAIL season ' + oldId + ': ' + seasonsTable.lastError()) failed = failed + 1 } else { seasonFresh = seasonFresh + 1 }
  262. }
  263. }
  264. report.push('seasons ' + seasonFresh + ' new, ' + seasonKnown + ' already there (old ' + seasonLines.length + ' -> new ' + seasonsTable.count() + '), show refs skipped ' + seasonShowMissing + ', episode refs skipped ' + seasonEpisodesMissing)
  265. // ---- Episode (show/season resolved; season null where the OLD link was already null or
  266. // dangling — pre-existing in the export, not introduced here) -------------------------
  267. episodeFresh = 0
  268. episodeKnown = 0
  269. episodeShowMissing = 0
  270. episodeSeasonMissing = 0
  271. for (l of episodeLines) {
  272. let d = JSON.parse(l)
  273. let oldId = oidOf(d._id)
  274. let id = episodeMap[oldId]
  275. if (episodesTable.fetch(id) != null) {
  276. episodeKnown = episodeKnown + 1
  277. } else {
  278. let show = showMap[oidOf(d.show._id)]
  279. if (show == null) { episodeShowMissing = episodeShowMissing + 1 }
  280. let oldSeasonId = oidOf(d.season._id)
  281. let season = oldSeasonId == null ? null : seasonMap[oldSeasonId]
  282. if (oldSeasonId != null && season == null) { episodeSeasonMissing = episodeSeasonMissing + 1 }
  283. let putId = episodesTable.put({
  284. id = id tmdbId = numOf(d.tmdbId) episodeNumber = numOf(d.episodeNumber) seasonNumber = numOf(d.seasonNumber)
  285. title = d.title summary = d.summary release = d.release show = show season = season oldId = oldId
  286. })
  287. if (putId == null) { console.log('FAIL episode ' + oldId + ': ' + episodesTable.lastError()) failed = failed + 1 } else { episodeFresh = episodeFresh + 1 }
  288. }
  289. }
  290. report.push('episodes ' + episodeFresh + ' new, ' + episodeKnown + ' already there (old ' + episodeLines.length + ' -> new ' + episodesTable.count() + '), show refs skipped ' + episodeShowMissing + ', season refs left null (pre-existing) ' + episodeSeasonMissing)
  291. // ---- the single old user -> this app's user for ident short id az5b2 ---------------
  292. creator = ensureUser('az5b2')
  293. if (creator == null) { hlError('could not create/find the user for az5b2') }
  294. userId = creator.id
  295. // ---- Follow ---------------------------------------------------------------------
  296. followFresh = 0
  297. followKnown = 0
  298. followMissing = 0
  299. for (l of followLines) {
  300. let d = JSON.parse(l)
  301. let oldId = oidOf(d._id)
  302. let id = resolveId(followsTable, oldId)
  303. if (followsTable.fetch(id) != null) {
  304. followKnown = followKnown + 1
  305. } else {
  306. let show = showMap[oidOf(d.oid)]
  307. if (show == null) { followMissing = followMissing + 1 }
  308. let putId = followsTable.put({ id = id show = show user = userId at = dateOf(d._createdAt) oldId = oldId })
  309. if (putId == null) { console.log('FAIL follow ' + oldId + ': ' + followsTable.lastError()) failed = failed + 1 } else { followFresh = followFresh + 1 }
  310. }
  311. }
  312. report.push('follows ' + followFresh + ' new, ' + followKnown + ' already there (old ' + followLines.length + ' -> new ' + followsTable.count() + '), show refs skipped ' + followMissing)
  313. // ---- Watch (target null where the OLD target no longer exists — pre-existing) -----
  314. watchFresh = 0
  315. watchKnown = 0
  316. watchMissing = 0
  317. for (l of watchLines) {
  318. let d = JSON.parse(l)
  319. let oldId = oidOf(d._id)
  320. let id = resolveId(watchesTable, oldId)
  321. if (watchesTable.fetch(id) != null) {
  322. watchKnown = watchKnown + 1
  323. } else {
  324. let kind = d.type
  325. let oldTarget = oidOf(d.oid)
  326. let target = kind == 'episode' ? episodeMap[oldTarget] : seasonMap[oldTarget]
  327. if (target == null) { watchMissing = watchMissing + 1 }
  328. let putId = watchesTable.put({ id = id targetType = kind target = target user = userId at = dateOf(d._createdAt) oldId = oldId })
  329. if (putId == null) { console.log('FAIL watch ' + oldId + ': ' + watchesTable.lastError()) failed = failed + 1 } else { watchFresh = watchFresh + 1 }
  330. }
  331. }
  332. report.push('watches ' + watchFresh + ' new, ' + watchKnown + ' already there (old ' + watchLines.length + ' -> new ' + watchesTable.count() + '), target refs left null (pre-existing) ' + watchMissing)
  333. genresTable.persist()
  334. personsTable.persist()
  335. showsTable.persist()
  336. seasonsTable.persist()
  337. episodesTable.persist()
  338. followsTable.persist()
  339. watchesTable.persist()
  340. for (line of report) { console.log('migrate: ' + line) }
  341. console.log('migrate: ' + failed + ' failed (' + oldDir + ' -> ' + storageDir + ')')

Branches

Latest commits

  • 139fafd8tracker#16: short bio (4 lines, click = all), real-data check script, README + STATUSmre
  • 93be9476tracker#16: person pages /person/<slug> with the filmography fetched from TMDB on the first visit (step by step), gatemre
  • 47a3cae6STATUS: mission 053 merge commit idsmre
  • dcc5eecaMerge branch 't14-search'mre
  • 03edc783Merge branch 't15-tvmaze'mre
  • 71b46345tracker#15: numbering check by date or title, placeholder titles in other languages, docs + real-data proofmre
  • 6bb2daf1tracker#13: homepage (tiles, intro, latest movies/shows), /shows, /movies/page/N, /my/movies; lists cached in memorymre
  • b8bd1157tracker#14: README + STATUS (search, real-data numbers, gate, merge notes)mre
  • 65c694a8tracker#14: search — header magnifier, /search/<text> (in-memory word-prefix index over titles + people), Fetch from web (TMDB search/multi, ours left out), Add = import via syncShow; gate +25 checks, real-data scriptmre
  • 34f2c15btracker#15: TVmaze merge in the sync (gaps only: new episodes/seasons, empty titles/air dates; numbering check), fake TVmaze episodes + gatemre
  • cbdc4ea7tracker#12: link icons TMDB/IMDb/TVDB/TVmaze; sync fills missing ids (TVmaze lookup); movies fetched via /movie/mre
  • b105bcd8tracker#11: Hybriel master ff51cf46 (checks no longer vanish), mobile-first styles, carets, follow button, sign-in modal, inverted check, orange castmre
  • 31b758aatracker#10: installable app (manifest, service worker, offline shell), own icon + faviconmre
  • 2fa9d997tracker#9: TMDB sync (followed shows: seasons, episodes, posters), tools/sync-tmdb.hl + daily run 04:00 UTC, fake TMDB in gatemre
  • 49e1f61edeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 54070a4etracker#8: /my/unwatched + /my/schedule (301 from old), S01E01, title (year), 1 episode, watched-set lookup (unwatched 15s -> 1s)mre
  • 3251488atracker#7: /my/shows (followed shows, newest follow first, poster, title, last watched SxxEyy); gate can take screenshots (TRACKER_GATE_SHOTS)mre
  • 44b7d9f9tracker#6: /schedule — upcoming episodes of followed shows, soonest firstmre
  • 91c9fc8ctracker#5: /unwatched — unwatched released episodes of followed shows, newest firstmre
  • a97c0295tracker#4: show page /shows/:slug (header, seasons, episodes, watch checks) + tools/relink-episode-seasons.hlmre