tracker
All repositories: gitoria
24.3 KB
// merge.hl — DUPLICATE TITLES MERGED (tracker.worldapi.org#31, mission 026). The architect: "Every TMDB title exists once in our// data; watches, follows and seasons all hang on that one record."//// WHERE THEY CAME FROM: the old tracker. On the live copy (2026-10-03) 68 type+tmdbId pairs were held by 157 records — every one// of them a migrated row (Mongo `oldId`, created 2022–2023 in runs of consecutive ids: Archive 81 ×6, Peripheral ×4, Ally// McBeal ×2 …); not one was made by this app. The app's two creating paths look the title up first (search.hl importTitle,// people.hl fillStep → search.hl titleIdByTmdb) and their records carry `oldId = tmdb-<tv|movie>-<id>`, which the table's// `!oldId` UNIQUE index refuses twice. Seasons the same way: 449 migrated seasons name a title that does not list them (the old// tracker showed a title's seasons by query); the daily sync goes by the list, so it made a SECOND season where TMDB has that// number (Reacher: the creator's S3 watches sat on the unlisted migrated S3, the page showed the sync's empty S3).//// THE REPAIR (in the app, on its own clock like the short-id backfill — no TMDB, one item per tick; project.hl `mergeTick`):// 1. per duplicate group: the KEEPER = the record with follows/watches (most first), else the one with its old short id// (shortids.hl), else the oldest (the Mongo id's time, else `imported`). Into it, from every other record:// follows + watches (a user who already has the keeper's: the moved row gets `target`/`show` null + `mergedInto` = the// row it repeats — no row is deleted), seasons the keeper lacks (moved: `show` = keeper), seasons it has (merged// episode by episode, below), cast + crew entries it lacks, the persons' credits, the timelines (franchises.hl// titleMerged), empty text/id fields, the poster file if the keeper has none. The keeper takes the plain slug of its// title when a duplicate held it (Archive 81: "Archive-82" → "Archive-81"; its former slug in `oldSlugs`).// The other record becomes a TOMBSTONE (shows.hl): `mergedInto`, `mergedAt`, `mergedTmdbId` (tmdbId null), `mergedSeasons`// (seasons []). Its slug and short id lead to the keeper (shows.hl movedSlugs → project.hl 301, shortids.hl shortIdPath).// 2. per title with seasons that name it but are not in its list (STRAY seasons): such a season with a listed TWIN (same// number) is merged into the twin; one without is LINKED into the list only when someone watched it or one of its episodes// (recorded in the title's `linkedSeasons`). The other strays stay as they are: linking all 393 with episodes on the live// copy put 1,091 never-watched old talk-show episodes on the creator's /my/unwatched (1,273 → 2,364 rows). When the sync// later makes a season of that number, the next start merges the stray into it.// MERGING A SEASON INTO ITS TWIN: the season's watches → the twin; each episode: the twin's episode of that number gets its// watches (the episode becomes `mergedInto` = that one), an episode the twin lacks MOVES into the twin. The season gets// `mergedInto` = the twin, `mergedEpisodes` (episodes []).// RESUMES: the queue is built at start from what is still duplicated / stray — a restart in the middle goes on (a half-moved// group picks the same keeper again: it already has the moved follows/watches). A third start: "merge: nothing to do".// Persisted every 10 items and at the end. Log: start, one line per tombstone and per repaired title, done.//// ONE PROCESS PER STORAGE (hybriel#21): the tables are the app's own. Ids are unique (count-duplicate-ids), so update() is safe// (hybriel#113, tmdbsync.hl).import { now } from 'hl:time'import { readBytes, writeFile, exists } from 'hl:fs'import { showsTable, seasonsTable, episodesTable, personsTable, storageDir, noteSlugsOf, forgetEpisodeKey, posterName } from './shows.hl'import { followsTable } from './follows.hl'import { watchesTable, forgetUserWatches } from './watches.hl'import { searchTitleMerged, slugBaseOf } from './search.hl'import { refreshCatalogShow } from './catalog.hl'import { hasOldShortId } from './shortids.hl'import { titleMerged } from './franchises.hl'static hexDigits = '0123456789abcdef'// the empty fields of a keeper a duplicate may fill (never ids, slugs, flags or the job markers)static fillFields = ['summary', 'tmdbSummary', 'tvmazeSummary', 'imdbId', 'tvdbId', 'tvmzId', 'homepage', 'tagline', 'release', 'year', 'language','migratedSummary', 'migratedTitle', 'migratedGenres', 'migratedHomepage', 'migratedTagline']// the work, [{ kind ('title' | 'seasons'), ids (a group's record ids) | id (a title) }], worked off from `run.pos`; filled in place// (a static list is never reassigned)static queue = []// watch id lists by target, follow id lists by show, person ids by the duplicates' ids they credit, seasons naming a title that// does not list them — built once by mergePrepare, kept up to date by the moves (flat maps, written key by key)static watchIds = {}static followIds = {}static creditedBy = {}static strays = {}static seasonsByDup = {}static run = { built = false pos = 0 groups = 0 records = 0 strayTitles = 0 ms = 0 }static totals = { groups = 0 tombstones = 0 follows = 0 followRepeats = 0 watches = 0 watchRepeats = 0 seasonsMoved = 0 seasonsMerged = 0seasonsLinked = 0 episodesMoved = 0 episodesMerged = 0 cast = 0 people = 0 timelines = 0 slugs = 0 posters = 0 fields = 0 titles = 0 }static listOf = (&v) => { return v == null ? [] : v }static isEmpty = (&v) => { return v == null || (hlTypeName(v) == 'String' && v.trim() == '') }// ---- the queue ------------------------------------------------------------------------------------------------------------// one scan of shows, seasons, follows, watches and persons: the duplicate groups, the titles with stray seasons, the indexesstatic mergePrepare = () => {if (run.built) { return queue.length }run.built = truelet t0 = now()let byKey = {}let order = []let listedBy = {}for (s of showsTable.find(null, null)) {if (s.mergedInto == null) {for (x of listOf(s.seasons)) { listedBy[x] = s.id }if (s.tmdbId != null) {let k = s.type + ':' + s.tmdbIdif (byKey[k] == null) {byKey[k] = [s.id]order.push(k)} else { byKey[k].push(s.id) }}}}let dup = {}for (k of order) {let ids = byKey[k]if (ids.length > 1) {queue.push({ kind = 'title' key = k ids = ids })run.groups = run.groups + 1run.records = run.records + ids.lengthfor (id of ids) { dup[id] = true }}}for (w of watchesTable.find(null, null)) {if (w.target != null) { if (watchIds[w.target] == null) { watchIds[w.target] = [w.id] } else { watchIds[w.target].push(w.id) } }}// seasons: a duplicate's (all of them, listed or not) and the stray ones (naming a live title that does not list them)let listedNum = {}let strayList = []for (x of seasonsTable.find(null, null)) {if (x.show != null && x.mergedInto == null) {if (dup[x.show] == true) {if (seasonsByDup[x.show] == null) { seasonsByDup[x.show] = [x.id] } else { seasonsByDup[x.show].push(x.id) }}if (listedBy[x.id] != null) { listedNum[listedBy[x.id] + '#' + x.seasonNumber] = true }else { strayList.push({ id = x.id show = x.show n = x.seasonNumber watched = hasWatches(x) }) }}}// a stray season is work when it has a listed twin or watches; its title must be live and no duplicate (a duplicate's seasons// go with its group)let strayShows = []for (st of strayList) {if (dup[st.show] != true && (st.watched || listedNum[st.show + '#' + st.n] == true)) {if (strays[st.show] == null) {strays[st.show] = [st.id]strayShows.push(st.show)} else { strays[st.show].push(st.id) }}}for (id of strayShows) {let s = showsTable.fetch(id)if (s != null && s.mergedInto == null) {queue.push({ kind = 'seasons' id = id })run.strayTitles = run.strayTitles + 1}}if (queue.length > 0) {for (f of followsTable.find(null, null)) {if (f.show != null) { if (followIds[f.show] == null) { followIds[f.show] = [f.id] } else { followIds[f.show].push(f.id) } }}if (run.groups > 0) {for (p of personsTable.find(null, null)) {if (p.shows != null) {for (c of p.shows) {if (c != null && c.show != null && dup[c.show] == true) {if (creditedBy[c.show] == null) { creditedBy[c.show] = [p.id] } else if (!creditedBy[c.show].includes(p.id)) { creditedBy[c.show].push(p.id) }}}}}}}run.ms = now() - t0return queue.length}// anyone's watch on the season or one of its episodes (watchIds is built)static hasWatches = (&season) => {if (watchIds[season.id] != null && watchIds[season.id].length > 0) { return true }for (eid of listOf(season.episodes)) { if (watchIds[eid] != null && watchIds[eid].length > 0) { return true } }return false}static mergeLeft = () => { return queue.length - run.pos }static mergeStats = () => { return run }static mergeTotals = () => { return totals }static mergeLine = () => {let t = totalsreturn 'groups=' + t.groups + ' tombstones=' + t.tombstones + ' follows=' + t.follows + ' followRepeats=' + t.followRepeats + ' watches=' + t.watches+ ' watchRepeats=' + t.watchRepeats + ' seasonsMoved=' + t.seasonsMoved + ' seasonsMerged=' + t.seasonsMerged + ' seasonsLinked=' + t.seasonsLinked+ ' episodesMoved=' + t.episodesMoved + ' episodesMerged=' + t.episodesMerged + ' cast=' + t.cast + ' people=' + t.people + ' timelines=' + t.timelines+ ' slugs=' + t.slugs + ' posters=' + t.posters + ' fields=' + t.fields + ' strayTitles=' + t.titles}static persistMerge = () => {showsTable.persist()seasonsTable.persist()episodesTable.persist()followsTable.persist()watchesTable.persist()personsTable.persist()return true}// ---- moving user data ------------------------------------------------------------------------------------------------------// every watch of `fromId` → `toId` (an episode, season or movie id); a user who already has a watch on `toId` keeps that one and// the moved row is parked (target null, `mergedInto` = the row it repeats). Every moved row keeps where it was (`mergedTarget`).static moveWatches = (&fromId, &toId) => {if (watchIds[fromId] == null) { return 0 }let ids = []for (x of watchIds[fromId]) { ids.push(x) }let mine = {}if (watchIds[toId] != null) {for (x of watchIds[toId]) {let w = watchesTable.fetch(x)if (w != null && w.target == toId) { mine[w.user] = w.id }}}let moved = []let n = 0for (wid of ids) {let w = watchesTable.fetch(wid)if (w != null && w.target == fromId) {let x = wx.mergedTarget = fromIdif (mine[x.user] != null) {x.target = nullx.mergedInto = mine[x.user]totals.watchRepeats = totals.watchRepeats + 1} else {x.target = toIdmine[x.user] = x.idmoved.push(x.id)totals.watches = totals.watches + 1}if (watchesTable.update(x.id, x) == null) { console.log('merge: watch ' + x.id + ' not stored: ' + watchesTable.lastError()) } else { n = n + 1 }forgetUserWatches(x.user)}}watchIds[fromId] = []if (moved.length > 0) {if (watchIds[toId] == null) { watchIds[toId] = moved } else { for (x of moved) { watchIds[toId].push(x) } }}return n}// every follow of title `fromId` → `toId`, the same way (`mergedShow` = where it was)static moveFollows = (&fromId, &toId) => {if (followIds[fromId] == null) { return 0 }let ids = []for (x of followIds[fromId]) { ids.push(x) }let mine = {}if (followIds[toId] != null) {for (x of followIds[toId]) {let f = followsTable.fetch(x)if (f != null && f.show == toId) { mine[f.user] = f.id }}}let moved = []for (fid of ids) {let f = followsTable.fetch(fid)if (f != null && f.show == fromId) {let x = fx.mergedShow = fromIdif (mine[x.user] != null) {x.show = nullx.mergedInto = mine[x.user]totals.followRepeats = totals.followRepeats + 1} else {x.show = toIdmine[x.user] = x.idmoved.push(x.id)totals.follows = totals.follows + 1}if (followsTable.update(x.id, x) == null) { console.log('merge: follow ' + x.id + ' not stored: ' + followsTable.lastError()) }}}followIds[fromId] = []if (moved.length > 0) {if (followIds[toId] == null) { followIds[toId] = moved } else { for (x of moved) { followIds[toId].push(x) } }}return moved.length}// ---- seasons -------------------------------------------------------------------------------------------------------------------// season `x` into its twin `twinId` of title `titleId` (see the top) → the episode ids the twin gainedstatic mergeSeasonInto = (&seasonId, &twinId, &titleId) => {let x = seasonsTable.fetch(seasonId)let twin = seasonsTable.fetch(twinId)if (x == null || twin == null || x.mergedInto != null) { return [] }moveWatches(x.id, twin.id)let byNum = {}for (eid of listOf(twin.episodes)) {let e = episodesTable.fetch(eid)if (e != null) { byNum['' + e.episodeNumber] = e.id }}let gained = []let tid = twin.idfor (eid of listOf(x.episodes)) {let e = episodesTable.fetch(eid)if (e != null && e.mergedInto == null) {let other = byNum['' + e.episodeNumber]let y = eif (other != null && other != e.id) {moveWatches(e.id, other)y.mergedInto = othertotals.episodesMerged = totals.episodesMerged + 1} else if (other == null) {y.movedFrom = { show = e.show season = e.season }y.show = titleIdy.season = tidbyNum['' + e.episodeNumber] = e.idgained.push(e.id)totals.episodesMoved = totals.episodesMoved + 1}if (episodesTable.update(y.id, y) == null) { console.log('merge: episode ' + y.id + ' not stored: ' + episodesTable.lastError()) }forgetEpisodeKey(y.id)}}if (gained.length > 0) {let tw = seasonsTable.fetch(tid)let eps = listOf(tw.episodes)for (g of gained) { eps.push(g) }tw.episodes = epsif (seasonsTable.update(tw.id, tw) == null) { console.log('merge: season ' + tw.id + ' not stored: ' + seasonsTable.lastError()) }}let done = seasonsTable.fetch(seasonId)done.mergedInto = tiddone.mergedAt = now()done.mergedEpisodes = listOf(done.episodes)done.episodes = []if (seasonsTable.update(done.id, done) == null) { console.log('merge: season ' + done.id + ' not stored: ' + seasonsTable.lastError()) }totals.seasonsMerged = totals.seasonsMerged + 1return gained}// a season (and its episodes) moved to title `titleId` — it was a duplicate's and the keeper has no season of its numberstatic moveSeason = (&seasonId, &titleId) => {let x = seasonsTable.fetch(seasonId)if (x == null) { return false }let y = xy.movedFrom = { show = x.show }y.show = titleIdif (seasonsTable.update(y.id, y) == null) {console.log('merge: season ' + y.id + ' not stored: ' + seasonsTable.lastError())return false}for (eid of listOf(y.episodes)) {let e = episodesTable.fetch(eid)if (e != null) {let z = ez.movedFrom = { show = e.show season = e.season }z.show = titleIdif (episodesTable.update(z.id, z) == null) { console.log('merge: episode ' + z.id + ' not stored: ' + episodesTable.lastError()) }forgetEpisodeKey(z.id)}}totals.seasonsMoved = totals.seasonsMoved + 1return true}// a title's listed seasons by number → season idstatic seasonsByNumber = (&show) => {let out = {}for (sid of listOf(show.seasons)) {let s = seasonsTable.fetch(sid)if (s != null) { out['' + s.seasonNumber] = s.id }}return out}// ---- the keeper ---------------------------------------------------------------------------------------------------------------// follows + watches on the record, its seasons and their episodesstatic userDataOf = (&r) => {let n = listOf(followIds[r.id]).length + listOf(watchIds[r.id]).lengthlet sids = []for (x of listOf(r.seasons)) { sids.push(x) }for (x of listOf(seasonsByDup[r.id])) { if (!sids.includes(x)) { sids.push(x) } }for (sid of sids) {n = n + listOf(watchIds[sid]).lengthlet s = seasonsTable.fetch(sid)if (s != null) { for (eid of listOf(s.episodes)) { n = n + listOf(watchIds[eid]).length } }}return n}// when the record was made (ms): a Mongo id's first 8 hex digits are its seconds; else `imported`; else laststatic ageOf = (&r) => {let o = r.oldId != null ? '' + r.oldId : ''if (o.length == 24) {let v = 0let i = 0let ok = truewhile (ok && i < 8) {let d = hexDigits.indexOf(o.slice(i, i + 1))if (d < 0) { ok = false } else { v = v * 16 + d }i = i + 1}if (ok) { return v * 1000 }}if (r.imported != null && hlTypeName(r.imported) == 'Number') { return r.imported }return 9000000000000000}// → the keeper's id: most user data, then the old short id, then the oldest; equal → the firststatic pickKeeper = (&recs) => {let best = nulllet bestData = -1let bestOld = falselet bestAge = 0for (r of recs) {let d = userDataOf(r)let o = hasOldShortId(r)let a = ageOf(r)let better = best == null || d > bestData || (d == bestData && o && !bestOld) || (d == bestData && o == bestOld && a < bestAge)if (better) {best = r.idbestData = dbestOld = obestAge = a}}return best}// ---- one duplicate into its keeper ---------------------------------------------------------------------------------------------static creditKey = (&c) => { return c.person != null ? 'p' + c.person : 't' + c.tmdbId }static mergeTitleInto = (&dupId, &keeperId) => {let t = showsTable.fetch(dupId)let k0 = showsTable.fetch(keeperId)if (t == null || k0 == null || t.mergedInto != null) { return null }let line = { follows = moveFollows(t.id, keeperId) watches = moveWatches(t.id, keeperId) seasons = 0 merged = 0 people = 0 }// seasons: the listed ones first, then the ones naming itlet sids = []for (x of listOf(t.seasons)) { sids.push(x) }for (x of listOf(seasonsByDup[t.id])) { if (!sids.includes(x)) { sids.push(x) } }let have = seasonsByNumber(k0)let added = []for (sid of sids) {let s = seasonsTable.fetch(sid)if (s != null && s.mergedInto == null) {let twin = have['' + s.seasonNumber]if (twin != null && twin != s.id) {mergeSeasonInto(s.id, twin, keeperId)line.merged = line.merged + 1} else if (twin == null && moveSeason(s.id, keeperId)) {have['' + s.seasonNumber] = s.idadded.push(s.id)line.seasons = line.seasons + 1}}}// the persons' creditsfor (pid of listOf(creditedBy[t.id])) {let p = personsTable.fetch(pid)if (p != null) {let shows = []let changed = falsefor (c of listOf(p.shows)) {let x = cif (x != null && x.show == t.id) {x.show = keeperIdchanged = true}shows.push(x)}if (changed) {let q = pq.shows = showsif (personsTable.update(q.id, q) == null) { console.log('merge: person ' + q.id + ' not stored: ' + personsTable.lastError()) } else { line.people = line.people + 1 }}}}totals.people = totals.people + line.peopletotals.timelines = totals.timelines + titleMerged(t.id, keeperId)// the keeper: its new seasons, the cast/crew it lacks, empty fields, the poster, the plain sluglet k = showsTable.fetch(keeperId)let seasons = listOf(k.seasons)for (a of added) { seasons.push(a) }k.seasons = seasonslet castKeys = {}let cast = listOf(k.cast)for (c of cast) { if (c != null) { castKeys[creditKey(c)] = true } }for (c of listOf(t.cast)) {if (c != null && castKeys[creditKey(c)] != true) {castKeys[creditKey(c)] = truecast.push(c)totals.cast = totals.cast + 1}}k.cast = castlet crewKeys = {}let crew = listOf(k.crew)for (c of crew) { if (c != null) { crewKeys[c.person + '|' + c.name + '|' + c.job] = true } }for (c of listOf(t.crew)) {if (c != null && crewKeys[c.person + '|' + c.name + '|' + c.job] != true) {crewKeys[c.person + '|' + c.name + '|' + c.job] = truecrew.push(c)totals.cast = totals.cast + 1}}if (crew.length > 0) { k.crew = crew }for (f of fillFields) {if (isEmpty(k[f]) && !isEmpty(t[f])) {k[f] = t[f]totals.fields = totals.fields + 1}}let kp = posterName(k)let tp = posterName(t)if ((kp == null || !exists(storageDir + '/posters/' + kp)) && tp != null && exists(storageDir + '/posters/' + tp) && k.oldId != null) {writeFile(storageDir + '/posters/' + k.oldId + '.' + t.image, readBytes(storageDir + '/posters/' + tp), 420)k.image = t.imagetotals.posters = totals.posters + 1}let plain = slugBaseOf(k.title, '')if (plain != '' && t.urlSegment == plain && k.urlSegment != plain) {let olds = listOf(k.oldSlugs)if (k.urlSegment != null && !olds.includes(k.urlSegment)) { olds.push(k.urlSegment) }k.oldSlugs = oldsk.urlSegment = plaintotals.slugs = totals.slugs + 1}if (showsTable.update(k.id, k) == null) { console.log('merge: keeper ' + k.id + ' not stored: ' + showsTable.lastError()) }// the duplicate becomes a tombstone (last: a stop before this leaves a group the next start finishes)let d = showsTable.fetch(dupId)let tmdbId = d.tmdbIdlet type = d.typed.mergedInto = keeperIdd.mergedAt = now()d.mergedTmdbId = tmdbIdd.tmdbId = nulld.mergedSeasons = listOf(d.seasons)d.seasons = []if (showsTable.update(d.id, d) == null) {console.log('merge: tombstone ' + d.id + ' not stored: ' + showsTable.lastError())return null}totals.tombstones = totals.tombstones + 1noteSlugsOf(d)noteSlugsOf(showsTable.fetch(keeperId))searchTitleMerged(d.id, keeperId, type, tmdbId)refreshCatalogShow(d.id)refreshCatalogShow(keeperId)console.log('merge: "' + d.title + '" ' + d.id + ' [' + d.urlSegment + ' ' + d.shortId + '] → ' + keeperId + ' [' + showsTable.fetch(keeperId).urlSegment + ']'+ ' follows=' + line.follows + ' watches=' + line.watches + ' seasonsMoved=' + line.seasons + ' seasonsMerged=' + line.merged + ' people=' + line.people)return line}// ---- one title's stray seasons ----------------------------------------------------------------------------------------------static straySeasonsStep = (&showId) => {let show = showsTable.fetch(showId)if (show == null || show.mergedInto != null) { return null }let listed = listOf(show.seasons)let have = seasonsByNumber(show)// the ones with more episodes first: of two watched strays of one number the fuller one is linked, the other merged into itlet rows = []for (sid of listOf(strays[showId])) {let s = seasonsTable.fetch(sid)if (s != null && s.mergedInto == null && s.show == showId && !listed.includes(s.id)) {let row = { id = s.id n = s.seasonNumber eps = listOf(s.episodes).length watched = hasWatches(s) }let out = []let put = falsefor (r of rows) {if (!put && row.eps > r.eps) { out.push(row) put = true }out.push(r)}if (!put) { out.push(row) }rows = out}}let linked = []let merged = 0for (r of rows) {let twin = have['' + r.n]if (twin != null) {mergeSeasonInto(r.id, twin, showId)merged = merged + 1} else if (r.watched) {linked.push(r.id)have['' + r.n] = r.idtotals.seasonsLinked = totals.seasonsLinked + 1}}if (linked.length > 0) {let x = showsTable.fetch(showId)let seasons = listOf(x.seasons)let marks = listOf(x.linkedSeasons)for (l of linked) {seasons.push(l)marks.push(l)}x.seasons = seasons// which ones the repair linked (they were in the data, not in the list)x.linkedSeasons = marksif (showsTable.update(x.id, x) == null) { console.log('merge: title ' + x.id + ' not stored: ' + showsTable.lastError()) }}if (linked.length > 0 || merged > 0) {totals.titles = totals.titles + 1refreshCatalogShow(showId)console.log('merge: seasons of "' + show.title + '" ' + showId + ': linked=' + linked.length + ' merged=' + merged)}return null}// ONE item of the queue → { done, left }static mergeStep = () => {mergePrepare()if (run.pos >= queue.length) { return { done = true left = 0 } }let item = queue[run.pos]run.pos = run.pos + 1if (item.kind == 'title') {let recs = []for (id of item.ids) {let r = showsTable.fetch(id)if (r != null && r.mergedInto == null) { recs.push(r) }}if (recs.length > 1) {let keeper = pickKeeper(recs)for (r of recs) { if (r.id != keeper) { mergeTitleInto(r.id, keeper) } }totals.groups = totals.groups + 1// the keeper's own stray seasons (naming it, not listed — Reacher's kind) the same way as any title'sstrays[keeper] = listOf(seasonsByDup[keeper])straySeasonsStep(keeper)}} else {straySeasonsStep(item.id)}return { done = run.pos >= queue.length left = queue.length - run.pos }}
Branches
- mainmain branch
Latest commits
- 84e1b3e1tracker: reports 025 + 026mre
- dc40d859tracker#31 (mission 026): duplicate titles merged — the 68 type+tmdbId pairs held by 157 records were the old tracker's (all migrated); merge.hl repair job (own clock, before the TMDB jobs) keeps one keeper per title (follows/watches > old short id > oldest), moves follows, watches, seasons, cast, credits, timelines, tombstones the rest (mergedInto, never deleted), slugs + short ids 301 to the keeper; stray seasons merged into their listed twin (Reacher S3 watches) or linked when watched; search import re-checks before its put; deploy.sh waits up to 90 s for 200; real copy 68 -> 0 dup ids, az5b2 follows/watches equal; gates 342/0, 32/0, 52/0mre
- 2667da05tracker#30 (mission 025): /my/ pages from slim cached title cards, episode rows and watch sets (after the jobs /my/series 1.8 s -> 0.06 s, /my/unwatched 4.1 -> 0.18 s); timeline page shows its name once; franchise widget under the poster/title; movies with TV leftovers (First Contact) go through the details repair; tools/count-tmdb-ids.hl; gates 327/0, 52/0, 32/0mre
- 3909810dantcolony#40: mission references in README/STATUS/docs point to the moved missionsmre
- e63b1d28antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 1cda451dtracker: Hybriel master 190aa11d (#127 both shapes, GC correctness fc838894) — conductor adopts despite /my/series 2.4x after jobs (memory 8.0 → 1.7 GB boot); see reports/071mre
- c1fa2f2etracker (mission 071): Hybriel master 190aa11d measured on the real copy vs the live binary 8590df63 — NOT adopted (after the first-start jobs /my/series 2.4x slower, /series 1.6x, RSS swings 7.4-12.2 GB; fresh it is flat at 1.7-2.2 GB and /my/unwatched faster), vendor stays 8efba065, candidate kept in .scratch/w071/vendor-190aa11d; tests/kinds.mjs: collection seed off (its TMDB request broke check 1 in 1 of 4 runs); tests/realdata-071.sh + realdata-071-bench.mjs + tools/realdata-071-table.py; README + STATUS (numbers, how to repeat); gates 325/0, 32/0, 50/0mre
- 8081350atracker docs (mission 070): README (summary, Config HL_GC_BYTES — kept at Hybriel's default, the 256 MiB setting is taken out of docker-compose.yml again: the jobs grew to 12+ GB with it too, see STATUS), Test (three gates), Deploy (first start ~50 min: kinds then seed, restart once after collections done, memory numbers), Vendored Hybriel 8efba065 + #48 audit, Files; STATUS mission 070 entry (merges, migrated counts, lambda audit, gates, RSS old vs new, how to repeat, open points); docs/kinds.md + docs/franchises.md job order; tests/realdata-070-*.sh, tools/count-migrated.hl, tools/ref-params.py, tools/lambda-audit.pymre
- 1ad19c8ctracker: re-vendor Hybriel master 8efba065 (#126 GC by bytes, #48 lambda parameters copy) (mission 070): bin/hybriel sha256 50361e95…, plugins core crypto data fetch fs http http1 mpackdb proc smtp time web; lambda audit: 13 lambdas change a passed record/list (11 through a local alias), no caller relies on it — unchanged; 319 read-only lambda parameters get & (no copy per call: /my/schedule 2.35 → 0.46 s, /my/unwatched 13.7 → 5.7 s on the real copy); /my/unwatched one merge sort instead of n² inserts; docker-compose HL_GC_BYTES=268435456; deploy.sh runs kinds.mjs + franchises.mjs too (default ports 8700–8710); tests/realdata-070.mjs; gates browser 325/0, kinds 32/0, franchises 50/0mre
- 46b21389tracker (mission 070, conductor): the sync never destroys migrated data — the one-time summary step MOVES a copied summary to migratedSummary (marker summaries-moved.txt) instead of clearing it; a migrated record's first title/genres/homepage/tagline TMDB replaces → migratedTitle/migratedGenres/migratedHomepage/migratedTagline, a migrated season's/episode's title/summary → migratedTitle/migratedSummary (set once); tests/peek-shows.hl prints them; gate 325/0mre
- 749019b0Merge t19 (tracker#19 franchises + timelines) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs/faketmdb.mjs, both sides kept; franchise/timeline pages get #20's typed heading (Franchise | …, Timeline | …), their title links via titlePath (/movies|/series|/shows); the collection seed waits for repair, kinds and credits too; franchises.mjs URLs + 2 new checks; gates browser 323/0, kinds 32/0, franchises 50/0mre
- daf49feaMerge t20 (tracker#20 typed headings + #21 series/shows split) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/search.hl/components/search.hl/browser.mjs/faketmdb.mjs, both sides kept; clock order backfill → repair → kinds → credits; withDetailsFields stores tmdbType + kind; gates: browser.mjs URLs → /series|/movies, typed h1 selectors; kinds.mjs repair/credits off, fixture name = seed name; browser 323/0, kinds 32/0mre
- 20e09d89Merge t18 (tracker#18 delta sync) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs, both sides kept; pageShowOf summary = summaryOfmre
- f83571c3tracker#18 (mission 067): daily sync by change lists — TMDB /tv|movie/changes (since the stored day, paged) + TVmaze /updates/shows → only our changed titles (followed: full step, unfollowed: light step — changed seasons, no TVmaze), full walk on first run / gap > 14 days / failed list; show record refreshed (title, tmdbSummary, tagline, status, genres …; renamed titles re-indexed); summary = the creator's own text (page: summary > tmdbSummary > tvmazeSummary), one-time clear of copied summaries (9,647 on the live copy); gate 261, tests/realdata-018*.mjs, README + STATUSmre
- 1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre
- f2b00674Merge t26 (tracker#26 + #28) into main (mission 062): short ids for every new person (castPersonId, guest route), guest stars stored on the title and created as people only when opened (/person/tmdb/<id>?show=<id> → 302), lean watch/follow clicks (showRow a small object, cast/crew from the slug, watches cached per user, face rows only after a season toggle); gate 311, tests/realdata-062.mjs, README + STATUSmre
- 0553b51ftracker#19 (mission 066): franchises and timelines — tables, /franchises, /franchises/<slug>, /timelines/<slug> (Timeline | Release sort, series by last episode), the Prequel | Timeline | Sequel widget with the franchise above, the creator's editor, TMDB collection seed in the app (resumes, paced); gate tests/franchises.mjs 48/0 + browser.mjs 266/0, tests/realdata-066.mjs, docs/franchises.md, README + STATUSmre
- fa1f9dfatracker#29 (mission 063): unwatched check muted grey outline + check (accent only on hover), watched stays solid — no code regression, the accent outline read as ticked; gate checks real checks visibly (computed style + screenshot pixel) on /my/unwatched, show, movie, /my/movies; gate 266, tests/realdata-063.mjs, README + STATUSmre
- 10bb3f93tracker#28 (mission 061): full cast (all seasons, main cast by episodes, guest stars) + crew (created by, directed by, written by, screenplay, story, music) — stored by the details completion, the daily sync, the search import (one details request) and a background credits job (resumes, RSS limit); show page collapsed after 20 with client-side Show all; showBySlug via a slug map; gate 249, tests/realdata-028.mjs, README + STATUSmre
- d8b12d67tracker#27 (mission 060): short ids for movies, series and persons — old 702 kept (data/old-short-ids.json), new random [a-z0-9]{5} unique across both, claimed at creation, background backfill (resumes), shown under poster/photo, /<shortId> → 301; gate 259, tests/realdata-060*, README + STATUSmre