tracker
All repositories: gitoria
18.0 KB
// tools/migrate.hl — ONE-OFF MIGRATION (tracker.worldapi.org#2): the old MongoDB export// (/media/STORAGE/projects/old-tracker/mongo-export/*.jsonl, EJSON, one document per line)// into this app's own mpackdb tables (storage/mpackdb/). EVERY record gets a NEW mpackdb id// (self-assigned below — see "why self-assigned ids" — not mpackdb's auto-generated one); the// old Mongo hex id is kept as `oldId` (a unique index: the idempotency key — a second run// finds the same id again via `oldId` and skips the row, printing "already there").//// genres.db { name, tmdbId, urlSegment, oldId }// persons.db { name, tmdbId, gender, urlSegment, birthDate, birthPlace, deathDate, image,// imdbId, shows [{ show, character }], oldId }// shows.db { title, summary, tmdbSummary, tmdbId, language, type, release, year, image,// urlSegment, episodesCount, homepage, imdbId, seasonsCount, status, tagline,// tvdbId, tvmzId, genres [genre id], cast [{ person, character, actor }],// seasons [season id], oldId }// seasons.db { seasonNumber, release, title, summary, show, episodes [episode id], oldId }// episodes.db { tmdbId, episodeNumber, seasonNumber, title, summary, release, show, season,// oldId } -- season is null where the OLD record's own season link was// already null or dangling (pre-existing in the export, not introduced here)// follows.db { show, user, at, oldId }// watches.db { targetType ('episode'|'season'), target, user, at, oldId } -- target is// null for the handful of watches whose old target no longer exists in the// export (pre-existing dangling refs; kept so counts still match)//// The single old user (User.jsonl, [email protected], password NOT taken over) becomes// this app's own user for ident short id az5b2 (users.hl `ensureUser`, storage/mpackdb/users.db// — the SAME table step 1's login already uses).//// WHY SELF-ASSIGNED IDS, NO update(): Show.seasons/Season.show and Show.cast/Person.shows are// each a genuine two-way reference (a show needs its seasons' ids; a season needs its show's id// — same for shows and persons via cast). The first version of this tool broke that circle the// obvious way — put() a bare record, come back later with fetch()+update() once the other side's// ids exist. That LOSES ROWS: reopening a table in a fresh process after a run that had done// update() on it read back fewer rows than were put (seen directly: count() dropped from 15157 to// 15152 after persons.db round-tripped through update() once — a genuine hl:mpackdb bug, filed as// a Hybriel gap, tracker.worldapi.org#2 — not something a vendored-plugin patch is allowed to fix// here). Tables that only ever got put() never lost a row in the same testing.// The fix: assign every new id ourselves (`resolveId`, 8 random bytes as hex, hl:crypto) in one// pass over each file BEFORE building any record, so by the time a record is actually built every// side of every reference is already a known id — one put() per row, with its id and every// reference already resolved. mpackdb's own auto id is never used; hybriel#23 already establishes// that put() keeps an id the record carries, which this leans on deliberately.//// Run (absolute paths; relative ones resolve against tools/, not the project root):// TRACKER_OLD_DATA=/media/STORAGE/projects/old-tracker/mongo-export \// TRACKER_STORAGE=$PWD/storage/mpackdb \// TRACKER_MIGRATE_TMP=$PWD/.scratch/migrate-tmp \// ./bin/hybriel tools/migrate.hlimport { MPackDB } from 'hl:mpackdb'import { env, run } from 'hl:proc'import { readFile, mkDir, exists } from 'hl:fs'import { randomBytes } from 'hl:crypto'import { ensureUser } from '../users.hl'static nl = ""oldDir = env('TRACKER_OLD_DATA')storageDir = env('TRACKER_STORAGE')tmpDir = env('TRACKER_MIGRATE_TMP')if (oldDir == null || !oldDir.startsWith('/') || storageDir == null || !storageDir.startsWith('/') || tmpDir == null || !tmpDir.startsWith('/')) {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')}// ---- reading the export --------------------------------------------------------------static linesOf = (path) => {let raw = readFile(path)if (raw == null) { return [] }let out = []for (l of raw.split(nl)) { if (l != '') { out.push(l) } }return out}static oidOf = (v) => {if (v == null) { return null }if (hlTypeName(v) == 'String') { return v }return v['$oid']}static numOf = (v) => {if (v == null) { return null }if (hlTypeName(v) == 'Number') { return v }let a = v['$numberInt']if (a == null) { a = v['$numberLong'] }return a == null ? null : toNumber(a)}static dateOf = (v) => {if (v == null) { return null }let d = v['$date']return d == null ? null : numOf(d)}// a JSON list field that may be absent or explicitly null (most Show.cast/.seasons and// nearly all Person.shows are) — iterating `null` with for-of aborts the whole runstatic listOr = (v) => { return v == null ? [] : v }// hl:fs readFile SILENTLY TRUNCATES at 10 MiB (fs.zig hl_fs_read_file, max_size = 1024*1024*10,// no error, no flag — reported as a Hybriel gap, tracker.worldapi.org#2): Show.jsonl (12.4 MB),// Season.jsonl (10.9 MB) and Episode.jsonl (140 MB) are all over it. Worked around here WITHOUT// touching the vendored plugin: `split` (coreutils, via hl:proc run — no shell, no JS) cuts the// export into <10 MB chunks in a scratch dir first; each chunk is read whole (well under the cap).static padNum4 = (n) => {let s = '' + nwhile (s.length < 4) { s = '0' + s }return s}static bigLinesOf = (path, chunkDir, perChunk) => {mkDir(chunkDir, 448)let r = run(['split', '-l', '' + perChunk, '-d', '-a', '4', '--additional-suffix=.jsonl', path, chunkDir + '/c_'])if (r == null || r.exit != 0) { hlError('split failed for ' + path) }let out = []let idx = 0while (true) {let fp = chunkDir + '/c_' + padNum4(idx) + '.jsonl'if (!exists(fp)) { break }for (l of linesOf(fp)) { out.push(l) }idx = idx + 1}if (idx == 0) { hlError('no chunks produced for ' + path) }return out}// ---- tables ---------------------------------------------------------------------------genresTable = new MPackDB(file = storageDir + '/genres.db', primaryKey = '@id', indexes = ['!oldId'])personsTable = new MPackDB(file = storageDir + '/persons.db', primaryKey = '@id', indexes = ['!oldId'])showsTable = new MPackDB(file = storageDir + '/shows.db', primaryKey = '@id', indexes = ['!oldId'])seasonsTable = new MPackDB(file = storageDir + '/seasons.db', primaryKey = '@id', indexes = ['!oldId'])episodesTable = new MPackDB(file = storageDir + '/episodes.db', primaryKey = '@id', indexes = ['!oldId'])followsTable = new MPackDB(file = storageDir + '/follows.db', primaryKey = '@id', indexes = ['!oldId'])watchesTable = new MPackDB(file = storageDir + '/watches.db', primaryKey = '@id', indexes = ['!oldId'])// the new id for an old row: reuse what a previous run already gave it (a point lookup on the// unique `oldId` index — proven reliable; it's a FULL table scan, find(null, null), that lost// rows together with update() in testing, so this never calls that), or a fresh random idstatic newId = () => { return randomBytes(8, 'hex') }static existingId = (table, oldId) => {let found = table.find('oldId', oldId)if (found != null && found.length > 0) { return found[0].id }return null}static resolveId = (table, oldId) => {let ex = existingId(table, oldId)return ex == null ? newId() : ex}report = []failed = 0// ---- read every file once (chunked for the big three) --------------------------------genreLines = linesOf(oldDir + '/Genre.jsonl')personLines = linesOf(oldDir + '/Person.jsonl')showLines = bigLinesOf(oldDir + '/Show.jsonl', tmpDir + '/show', 5000)seasonLines = bigLinesOf(oldDir + '/Season.jsonl', tmpDir + '/season', 3000)episodeLines = bigLinesOf(oldDir + '/Episode.jsonl', tmpDir + '/episode', 5000)followLines = linesOf(oldDir + '/Follow.jsonl')watchLines = linesOf(oldDir + '/Watch.jsonl')// ---- phase 1: id maps only (every old id -> its new id, self-assigned) ---------------// every reference below (Show.genres/cast/seasons, Season.show/episodes, Episode.show/season,// Person.shows, Follow.oid, Watch.oid) is resolved against one of these five maps, ALL of// which are complete before phase 2 builds a single record — that is what removes the need// for update() (see the file header).genreMap = {}for (l of genreLines) { let d = JSON.parse(l) let oldId = oidOf(d._id) genreMap[oldId] = resolveId(genresTable, oldId) }personMap = {}for (l of personLines) { let d = JSON.parse(l) let oldId = oidOf(d._id) personMap[oldId] = resolveId(personsTable, oldId) }showMap = {}for (l of showLines) { let d = JSON.parse(l) let oldId = oidOf(d._id) showMap[oldId] = resolveId(showsTable, oldId) }seasonMap = {}for (l of seasonLines) { let d = JSON.parse(l) let oldId = oidOf(d._id) seasonMap[oldId] = resolveId(seasonsTable, oldId) }episodeMap = {}for (l of episodeLines) { let d = JSON.parse(l) let oldId = oidOf(d._id) episodeMap[oldId] = resolveId(episodesTable, oldId) }// ---- phase 2: build and put the real records (one put() each, every reference resolved) ----// ---- Genre ----------------------------------------------------------------------------genreFresh = 0genreKnown = 0for (l of genreLines) {let d = JSON.parse(l)let oldId = oidOf(d._id)let id = genreMap[oldId]if (genresTable.fetch(id) != null) {genreKnown = genreKnown + 1} else {let putId = genresTable.put({ id = id name = d.name tmdbId = numOf(d.tmdbId) urlSegment = d.urlSegment oldId = oldId })if (putId == null) { console.log('FAIL genre ' + oldId + ': ' + genresTable.lastError()) failed = failed + 1 } else { genreFresh = genreFresh + 1 }}}report.push('genres ' + genreFresh + ' new, ' + genreKnown + ' already there (old ' + genreLines.length + ' -> new ' + genresTable.count() + ')')// ---- Person (shows resolved against showMap, already complete) -----------------------personFresh = 0personKnown = 0personShowsMissing = 0for (l of personLines) {let d = JSON.parse(l)let oldId = oidOf(d._id)let id = personMap[oldId]if (personsTable.fetch(id) != null) {personKnown = personKnown + 1} else {let shows = []for (s of listOr(d.shows)) {let shid = showMap[oidOf(s._id)]if (shid == null) { personShowsMissing = personShowsMissing + 1 } else { shows.push({ show = shid character = s.character }) }}let putId = personsTable.put({id = id name = d.name tmdbId = numOf(d.tmdbId) gender = numOf(d.gender) urlSegment = d.urlSegmentbirthDate = d.birthDate birthPlace = d.birthPlace deathDate = d.deathDate image = d.imageimdbId = d.imdbId shows = shows oldId = oldId})if (putId == null) { console.log('FAIL person ' + oldId + ': ' + personsTable.lastError()) failed = failed + 1 } else { personFresh = personFresh + 1 }}}report.push('persons ' + personFresh + ' new, ' + personKnown + ' already there (old ' + personLines.length + ' -> new ' + personsTable.count() + '), show refs skipped ' + personShowsMissing)// ---- Show (genres/cast/seasons resolved against genreMap/personMap/seasonMap) --------showFresh = 0showKnown = 0castMissing = 0showGenresMissing = 0showSeasonsMissing = 0for (l of showLines) {let d = JSON.parse(l)let oldId = oidOf(d._id)let id = showMap[oldId]if (showsTable.fetch(id) != null) {showKnown = showKnown + 1} else {let genres = []for (g of listOr(d.genres)) {let gid = genreMap[oidOf(g._id)]if (gid == null) { showGenresMissing = showGenresMissing + 1 } else { genres.push(gid) }}let cast = []for (c of listOr(d.cast)) {let pid = personMap[oidOf(c._id)]if (pid == null) { castMissing = castMissing + 1 } else { cast.push({ person = pid character = c.character actor = c.actor }) }}let seasons = []for (s of listOr(d.seasons)) {let sid = seasonMap[oidOf(s)]if (sid == null) { showSeasonsMissing = showSeasonsMissing + 1 } else { seasons.push(sid) }}let putId = showsTable.put({id = id title = d.title summary = d.summary tmdbSummary = d.tmdbSummary tmdbId = numOf(d.tmdbId)language = d.language type = d.type release = d.release year = numOf(d.year) image = d.imageurlSegment = d.urlSegment episodesCount = numOf(d.episodesCount) homepage = d.homepageimdbId = d.imdbId seasonsCount = numOf(d.seasonsCount) status = d.status tagline = d.taglinetvdbId = numOf(d.tvdbId) tvmzId = numOf(d.tvmzId) genres = genres cast = cast seasons = seasonsoldId = oldId})if (putId == null) { console.log('FAIL show ' + oldId + ': ' + showsTable.lastError()) failed = failed + 1 } else { showFresh = showFresh + 1 }}}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)// ---- Season (show resolved against showMap, episodes against episodeMap) -------------seasonFresh = 0seasonKnown = 0seasonShowMissing = 0seasonEpisodesMissing = 0for (l of seasonLines) {let d = JSON.parse(l)let oldId = oidOf(d._id)let id = seasonMap[oldId]if (seasonsTable.fetch(id) != null) {seasonKnown = seasonKnown + 1} else {let show = showMap[oidOf(d.show._id)]if (show == null) { seasonShowMissing = seasonShowMissing + 1 }let episodes = []for (e of listOr(d.episodes)) {let eid = episodeMap[oidOf(e)]if (eid == null) { seasonEpisodesMissing = seasonEpisodesMissing + 1 } else { episodes.push(eid) }}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 })if (putId == null) { console.log('FAIL season ' + oldId + ': ' + seasonsTable.lastError()) failed = failed + 1 } else { seasonFresh = seasonFresh + 1 }}}report.push('seasons ' + seasonFresh + ' new, ' + seasonKnown + ' already there (old ' + seasonLines.length + ' -> new ' + seasonsTable.count() + '), show refs skipped ' + seasonShowMissing + ', episode refs skipped ' + seasonEpisodesMissing)// ---- Episode (show/season resolved; season null where the OLD link was already null or// dangling — pre-existing in the export, not introduced here) -------------------------episodeFresh = 0episodeKnown = 0episodeShowMissing = 0episodeSeasonMissing = 0for (l of episodeLines) {let d = JSON.parse(l)let oldId = oidOf(d._id)let id = episodeMap[oldId]if (episodesTable.fetch(id) != null) {episodeKnown = episodeKnown + 1} else {let show = showMap[oidOf(d.show._id)]if (show == null) { episodeShowMissing = episodeShowMissing + 1 }let oldSeasonId = oidOf(d.season._id)let season = oldSeasonId == null ? null : seasonMap[oldSeasonId]if (oldSeasonId != null && season == null) { episodeSeasonMissing = episodeSeasonMissing + 1 }let putId = episodesTable.put({id = id tmdbId = numOf(d.tmdbId) episodeNumber = numOf(d.episodeNumber) seasonNumber = numOf(d.seasonNumber)title = d.title summary = d.summary release = d.release show = show season = season oldId = oldId})if (putId == null) { console.log('FAIL episode ' + oldId + ': ' + episodesTable.lastError()) failed = failed + 1 } else { episodeFresh = episodeFresh + 1 }}}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)// ---- the single old user -> this app's user for ident short id az5b2 ---------------creator = ensureUser('az5b2')if (creator == null) { hlError('could not create/find the user for az5b2') }userId = creator.id// ---- Follow ---------------------------------------------------------------------followFresh = 0followKnown = 0followMissing = 0for (l of followLines) {let d = JSON.parse(l)let oldId = oidOf(d._id)let id = resolveId(followsTable, oldId)if (followsTable.fetch(id) != null) {followKnown = followKnown + 1} else {let show = showMap[oidOf(d.oid)]if (show == null) { followMissing = followMissing + 1 }let putId = followsTable.put({ id = id show = show user = userId at = dateOf(d._createdAt) oldId = oldId })if (putId == null) { console.log('FAIL follow ' + oldId + ': ' + followsTable.lastError()) failed = failed + 1 } else { followFresh = followFresh + 1 }}}report.push('follows ' + followFresh + ' new, ' + followKnown + ' already there (old ' + followLines.length + ' -> new ' + followsTable.count() + '), show refs skipped ' + followMissing)// ---- Watch (target null where the OLD target no longer exists — pre-existing) -----watchFresh = 0watchKnown = 0watchMissing = 0for (l of watchLines) {let d = JSON.parse(l)let oldId = oidOf(d._id)let id = resolveId(watchesTable, oldId)if (watchesTable.fetch(id) != null) {watchKnown = watchKnown + 1} else {let kind = d.typelet oldTarget = oidOf(d.oid)let target = kind == 'episode' ? episodeMap[oldTarget] : seasonMap[oldTarget]if (target == null) { watchMissing = watchMissing + 1 }let putId = watchesTable.put({ id = id targetType = kind target = target user = userId at = dateOf(d._createdAt) oldId = oldId })if (putId == null) { console.log('FAIL watch ' + oldId + ': ' + watchesTable.lastError()) failed = failed + 1 } else { watchFresh = watchFresh + 1 }}}report.push('watches ' + watchFresh + ' new, ' + watchKnown + ' already there (old ' + watchLines.length + ' -> new ' + watchesTable.count() + '), target refs left null (pre-existing) ' + watchMissing)genresTable.persist()personsTable.persist()showsTable.persist()seasonsTable.persist()episodesTable.persist()followsTable.persist()watchesTable.persist()for (line of report) { console.log('migrate: ' + line) }console.log('migrate: ' + failed + ' failed (' + oldDir + ' -> ' + storageDir + ')')
Branches
- mainmain branch
Latest commits
- f14db671tracker#22-#25 (mission 058): episode air dates, season check = all episodes watched, movie watched check (+ /my/movies count), /genres/<genre> pages (movies + series, newest first, paginated); gate 238, tests/realdata-058.mjs, README + STATUSmre
- 1704ec45tracker#17 (mission 057): season caret down/up, skeleton rows while a season loads, sessionless showSeasonEpisodes face (no page re-mount), client-only close; gate 214, tests/realdata-057.mjs, README + STATUSmre
- f2fe3e36mission 056: README + STATUS (merge, fixes, Hybriel 8590df63, real-data check), tests/realdata-056.mjs, tools/check-public-slugs.hlmre
- f40c250emission 056: re-vendor hybriel master 8590df63 (#121, #122); an adult title's page is Not found for non-followers; gate: leave the page before stopping the servermre
- 2b7fdd6cmission 056: signed-out header one row on phones ("Log in", nowrap), backfill skips adult titles' posters, gate checksmre
- 2c53d5efMerge branch 't16-person' (tracker#16 person pages) into main; filmography shows only public titles (054 adult flag), gate race fix (backfill start line)mre
- c171227emission 054: hide adult/unknown titles from the public lists and the search; in-app adult-flag backfill (TMDB details + poster per title, resumes), gate + real-data proofmre
- 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