tracker
All repositories: gitoria
24.0 KB
// lib/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; jobs.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, sync.hl).import { now } from 'hl:time'import { readBytes, writeFile, exists } from 'hl:fs'import { postersDir, hexDigits, listOr } from './util.hl'import { showsTable, seasonsTable, episodesTable, personsTable, noteSlugsOf, forgetEpisodeKey, posterName } from './shows.hl'import { followsTable } from './follows.hl'import { watchesTable, forgetUserWatches } from './watches.hl'import { searchTitleMerged } from './search.hl'import { slugBaseOf } from './search-helpers.hl'import { refreshCatalogShow } from './catalog.hl'import { hasOldShortId } from './shortids.hl'import { titleMerged } from './franchises.hl'// 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 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 = truet0 = now()byKey = {}order = []listedBy = {}for (s of showsTable.find(null, null)) {if (s.mergedInto == null) {for (x of listOr(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) }}}}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)listedNum = {}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)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 listOr(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 = () => {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 }ids = []for (x of watchIds[fromId]) { ids.push(x) }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 }}}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 }ids = []for (x of followIds[fromId]) { ids.push(x) }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 }}}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) => {x = seasonsTable.fetch(seasonId)twin = seasonsTable.fetch(twinId)if (x == null || twin == null || x.mergedInto != null) { return [] }moveWatches(x.id, twin.id)byNum = {}for (eid of listOr(twin.episodes)) {let e = episodesTable.fetch(eid)if (e != null) { byNum['' + e.episodeNumber] = e.id }}gained = []tid = twin.idfor (eid of listOr(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) {tw = seasonsTable.fetch(tid)eps = listOr(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()) }}done = seasonsTable.fetch(seasonId)done.mergedInto = tiddone.mergedAt = now()done.mergedEpisodes = listOr(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) => {x = seasonsTable.fetch(seasonId)if (x == null) { return false }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 listOr(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) => {out = {}for (sid of listOr(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 = listOr(followIds[r.id]).length + listOr(watchIds[r.id]).lengthsids = []for (x of listOr(r.seasons)) { sids.push(x) }for (x of listOr(seasonsByDup[r.id])) { if (!sids.includes(x)) { sids.push(x) } }for (sid of sids) {n = n + listOr(watchIds[sid]).lengthlet s = seasonsTable.fetch(sid)if (s != null) { for (eid of listOr(s.episodes)) { n = n + listOr(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) => {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) => {t = showsTable.fetch(dupId)k0 = showsTable.fetch(keeperId)if (t == null || k0 == null || t.mergedInto != null) { return null }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 itsids = []for (x of listOr(t.seasons)) { sids.push(x) }for (x of listOr(seasonsByDup[t.id])) { if (!sids.includes(x)) { sids.push(x) } }have = seasonsByNumber(k0)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 listOr(creditedBy[t.id])) {let p = personsTable.fetch(pid)if (p != null) {let shows = []let changed = falsefor (c of listOr(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 slugk = showsTable.fetch(keeperId)seasons = listOr(k.seasons)for (a of added) { seasons.push(a) }k.seasons = seasonscastKeys = {}cast = listOr(k.cast)for (c of cast) { if (c != null) { castKeys[creditKey(c)] = true } }for (c of listOr(t.cast)) {if (c != null && castKeys[creditKey(c)] != true) {castKeys[creditKey(c)] = truecast.push(c)totals.cast = totals.cast + 1}}k.cast = castcrewKeys = {}crew = listOr(k.crew)for (c of crew) { if (c != null) { crewKeys[c.person + '|' + c.name + '|' + c.job] = true } }for (c of listOr(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}}kp = posterName(k)tp = posterName(t)if ((kp == null || !exists(postersDir + '/' + kp)) && tp != null && exists(postersDir + '/' + tp) && k.oldId != null) {writeFile(postersDir + '/' + k.oldId + '.' + t.image, readBytes(postersDir + '/' + tp), 420)k.image = t.imagetotals.posters = totals.posters + 1}plain = slugBaseOf(k.title, '')if (plain != '' && t.urlSegment == plain && k.urlSegment != plain) {olds = listOr(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)d = showsTable.fetch(dupId)tmdbId = d.tmdbIdtype = d.typed.mergedInto = keeperIdd.mergedAt = now()d.mergedTmdbId = tmdbIdd.tmdbId = nulld.mergedSeasons = listOr(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) => {show = showsTable.fetch(showId)if (show == null || show.mergedInto != null) { return null }listed = listOr(show.seasons)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 listOr(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 = listOr(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}}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) {x = showsTable.fetch(showId)seasons = listOr(x.seasons)marks = listOr(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 } }item = queue[run.pos]run.pos = run.pos + 1if (item.kind == 'title') {recs = []for (id of item.ids) {let r = showsTable.fetch(id)if (r != null && r.mergedInto == null) { recs.push(r) }}if (recs.length > 1) {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] = listOr(seasonsByDup[keeper])straySeasonsStep(keeper)}} else {straySeasonsStep(item.id)}return { done = run.pos >= queue.length left = queue.length - run.pos }}
Branches
- mainmain branch
Latest commits
- 9448d643tracker#35 follow-up (mission 033): TVmaze 'Running' is labelled 'Returning', or 'Airing' while a non-special episode of the two newest seasons is released within today +-7 days (data unchanged); gate fixtures Running/Airing/Aired + a special; browser 366/0, kinds 32/0, franchises 52/0, pager 229/0, check-theme 0mre
- 03ec792ftracker#38 (mission 033): pagination goes exactly to the clicked page — tilelist read the clicked button's text after pagination.hl's own handler had rebuilt the buttons (real clicks only); now li.current, else the button's own text; new gate tests/pager.mjs (5 lists x 11 pages, 390/1280, real + script clicks, Back/Forward) 229/0; browser 365/0, kinds 32/0, franchises 52/0, check-theme 0mre
- 96ba683adeploy.sh: a gate without a 'passed,' line (check-theme) no longer ends the scriptmre
- eb3b9205tracker: report 031mre
- 9b5d2e89tracker mission 031: README (What it does, Test: four gates + the #32 checks, Files: theme/, new pages), STATUS (real copy, A/B load, how to repeat, open points), LOGmre
- 39950e4ctracker#32 (mission 031): the WorldAPI theme (theme/ vendored verbatim from layouts.worldapi.org 85b5654; styles.hl inherits it: accent green-dark, type colours 1-6; own base/header rules, row lines, genre-pill and inverted-button frames removed, the season foldable keeps its line; check-theme 21 -> 0, 4th deploy gate; main actions class primary) and the #32 header (theme AppHeader/MainMenu/UserMenu/Sidebar/ContentFirst: desktop brand, search, Series|Shows|Movies|Genres|People, user icon with Unwatched..Settings, Logout; signed out the ident selector, phone the iD icon dropdown; phone menu in the sidebar overlay; marked entry by :has); /find -> /search/<q>, /genres, /people(/<letter>), /settings; main { ContentFirst { slot } } works around the hl:web one-line slot bug; gates 365/0, 32/0, 52/0, check-theme 0mre
- a386dc92tracker: reports 029 + 030mre
- 71e0fd7dtracker missions 029 + 030: README (What it does, Files, gate count), STATUS (real-copy numbers, how to repeat, open points), LOGmre
- d36ea6eatracker#34 + #35 (mission 030): Follow directly under the poster, as wide as the poster (show.hl, styles.hl); the status pill next to a series' title — TVmaze's status (new tvmazeStatus, stored by the sync's TVmaze merge) else TMDB's, TVmaze Ended + TMDB Canceled = Canceled, inverted (filled, dark text, no border), green running / yellow pending / red canceled / muted ended (shows.hl statusOf); the daily delta asks TVmaze's status of an unfollowed series TVmaze's change list names (dailysync.hl syncRunStep, sync.hl syncTvmazeStatus); the status backfill after the details repair (backfill.hl, jobs.hl statusTick; resumable, 550 ms per TVmaze request); gates 354/0, 32/0, 52/0mre
- 7d7d4487tracker#33 (mission 029): reduced titles — every title TMDB's details never went through this app (no detailsAt, no tmdbSync) is incomplete (shows.hl isIncomplete; the old tracker's migrated rows passed #26's test: 5,697 non-adult on the live copy, 691 series without seasons); the repair job does the visibly reduced first (shows.hl missingParts), the page completes one on open; a title TMDB has no poster for (The Remaining) shows the placeholder; tools/count-incomplete.hl; gate fixtures stand for synced titles (tmdbSync), tests/seed-reduced.hl + #33 checks; gates 347/0, 32/0, 52/0mre
- 661c2592tracker: report 028mre
- 27c916fatracker mission 028: README ("Code order", the new file map), STATUS (counts before/after, tests, how to repeat, open), LOGmre
- d924f398tracker mission 028: comments name the new files (sync.hl, dailysync.hl, backfill.hl, credits.hl, jobs.hl, images.hl …); tools/ref-params.py + tools/lambda-audit.py also scan lib/ (they globbed the root only), lambda-audit counts a plain `x = p` alias like `let x = p`mre
- 2e89b968tracker mission 028 (code order) 5/5 let: `let` only where a variable is reassigned — 667 never-reassigned lets became plain declarations (project.hl, lib/, components/, tools/, tests/); kept: 264 in loop bodies (a plain declaration there is 'Cannot reassign' on the 2nd pass), 234 reassigned, 27 whose name is also a member/outer/free name (a plain write would rebind it); tools/let-audit.py decides and fixes (README 'Code order'); tests/realdata-m028.{sh,mjs} = the page-output diff on a real copy; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 54796ff2tracker mission 028 (code order) 4/5 thin faces + last copies: the show page's check/follow faces call lib/watches.hl toggleWatched / toggleSeasonWatched (seasonAllWatched moved there) and lib/follows.hl toggleFollowed; both logins (header selector face, /login/callback) share lib/users.hl userOfCode; todayStr/listOf copies in components and the export readers copied into tools/migrate.hl + tools/old-short-ids.hl now once (lib/util.hl, lib/export.hl); gates 342/0, 32/0, 52/0; old-short-ids output byte-identical, migrate output identicalmre
- 06b078e3tracker mission 028 (code order) 3/5 project.hl is the map: config, routes, wiring and a feature → file index (914 → 258 lines); the background jobs (daily sync run, backfills, details repair, credits job, merge, short ids, collection seed) moved unchanged into lib/jobs.hl (a class: their state is reassigned every step, a static cannot be; one instance made after the server), the login callback into lib/users.hl, poster/photo serving into lib/images.hl, the /shows/<slug> rule into lib/shows.hl showsMovedPath; route handlers are thin wrappers; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 94716fd2tracker mission 028 (code order) 2/5 util + topics: lib/util.hl holds envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc once (were copied into up to 5 files); tmdbsync.hl split into tmdb.hl (TMDB/TVmaze requests), sync.hl (one title's sync), sync-helpers.hl, backfill.hl; details.hl split into details.hl, credits.hl, credits-helpers.hl (isIncomplete to shows.hl); search-helpers.hl (words, query, ranking, slugs); collections.hl (the TMDB collection seed, out of franchises.hl); deltasync.hl renamed dailysync.hl; no behaviour change: gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 186079b0tracker mission 028 (code order) 1/5 move: every root .hl except project.hl into lib/ (styles.hl into components/), import paths only; gates 342/0, 32/0, 52/0; real-copy pages identicalmre
- 4f47f181tracker: report 027mre
- dc1d4be4tracker mission 027: Hybriel master 06617221 vendored (plugin allocator fixes 3a781359 + 413f60e4); real copy RSS through first-start jobs + 400 loads flat ~2.55 GB (190aa11d 2.3 -> 5.6 GB), page times <= 1.1x; gates 342/0, 32/0, 52/0mre