tracker
All repositories: gitoria
25.1 KB
// lib/people.hl — THE PERSON PAGE's data (tracker.worldapi.org#16, components/person.hl `/person/<slug>`): a person by slug,// their facts and filmography, and the ONE-TIME fetch of their whole filmography from TMDB on the first visit. Creator:// "the movies come from artist scrolls. when one clicks on an artist that has no dataset yet the old version fetched that// artist and its entire bibilogrqphy so its profile was complete".//// personBySlug(slug) the person (slug = `urlSegment`) — a slug → id map built once (15k people, one scan)// personInfoOf(p) name, photo URL, born/died lines, bio// filmographyOf(p) their credits (`p.shows`): poster, title, year, role, kind — newest first, adult titles hidden// personFillStep(id) ONE step of the fetch, called again and again by the page until it answers `done`:// 1st call: GET <TMDB>/person/<tmdbId>?append_to_response=combined_credits (ONE request: details +// every cast/crew credit) + the photo (w185, like the posters); the person's facts are updated;// every next call stores the next `fillBatch` titles; the last one links the credits and marks// the person complete (`filmographyAt`, ms). hl:web serves one request at a time — between two// steps every other request is served, so a person with 300 credits never holds the server.// peoplePage(page) /people (tracker#37): everyone, the one updated last first, 24 per page — a view built once at boot// personPhotoOf(p) the photo of a /people tile and a search row: our file, else TMDB's (`tmdbProfile`), else the placeholder//// COMPLETE = `filmographyAt` set: never fetched again (the daily sync may refresh followed people later — not built).// Migrated people are NOT complete (153 of them carry the old tracker's credits — kept, the fetch adds the rest).// A title we don't have → a MINIMAL show record (title, year, type, tmdbId, `adult`, overview, genres by TMDB id, language;// no seasons, no poster file; `minimal = true` — details.hl completes it when it is opened or by the repair job, tracker#26), shaped like search.hl importTitle's// (`oldId tmdb-<tv|movie>-<id>`, a free slug), indexed for the search and put into the public lists (catalog.hl).// The person's credits (`shows`, the migrated field): [{ show, character, posterPath, adult }] — `character` is the role// text (the characters, then crew jobs like "Director", " / " between them); `posterPath` = TMDB's, so the page can show// TMDB's small poster (w92, like search's web rows) until we have the file. ADULT (TMDB's flag, mission 054): a row is// shown only for a PUBLIC title (shows.hl `isPublicTitle`: `adult == false`, unknown hidden like the public lists) whose// credit is not adult either (mission 056 merge).// ONE PROCESS PER STORAGE (hybriel#21); new rows get self-assigned 16-hex ids (hybriel#113), like sync.hl.import { now } from 'hl:time'import { fetch } from 'hl:fetch'import { writeFile, mkDir, exists } from 'hl:fs'import { profilesDir, newId, textOr, listOr, sortDesc } from './util.hl'import { personsTable, showsTable, genresTable, showById, showYear, posterUrlOf, isPublicTitle, titlePath, typeWordOf, typeClassOf, liveShowOf } from './shows.hl'import { tmdbGet, tmdbImageBase } from './tmdb.hl'import { titleIdByTmdb, indexShow, slugOf, indexPerson } from './search.hl'import { slugBaseOf } from './search-helpers.hl'import { refreshCatalogShow, pagesOf, pageNumberOf, pageSize } from './catalog.hl'import { claimShortId } from './shortids.hl'// titles stored per step (real data: see STATUS.md ticket #16 for the per-step time)static fillBatch = 40// ---- the person -------------------------------------------------------------------------------------------------static slugIndex = { built = false ids = {} }static ensureSlugIndex = () => {if (!slugIndex.built) {m = {}for (p of personsTable.find(null, null)) { if (p.urlSegment != null) { m[p.urlSegment] = p.id } }slugIndex.ids = mslugIndex.built = true}return true}static personBySlug = (&slug) => {if (slug == null || hlTypeName(slug) != 'String' || slug == '') { return null }ensureSlugIndex()id = slugIndex.ids[slug]return id == null ? null : personsTable.fetch(id)}static isComplete = (&p) => { return p.filmographyAt != null }// the page asks TMDB for this person: not complete yet, and TMDB knows themstatic needsFill = (&p) => { return !isComplete(p) && p.tmdbId != null }// the photo: /profiles/<oldId>.<ext> (images.hl) — only when the file is there (the migrated `image` = 'jpg'// of 129 people never came with a file)static profileName = (&p) => {if (p.oldId == null || p.image == null || p.image == '') { return null }return p.oldId + '.' + p.image}static photoUrlOf = (&p) => {name = profileName(p)return name != null && exists(profilesDir + '/' + name) ? '/profiles/' + name : ''}static monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']// '1986-03-04' → '4 March 1986'; a bare year or anything else as it isstatic dateText = (&d) => {t = textOr(d)if (t == null) { return null }if (t.length < 10) { return t }m = toNumber(t.slice(5, 7))day = toNumber(t.slice(8, 10))if (m == null || day == null || m < 1 || m > 12) { return t }return day + ' ' + monthNames[m - 1] + ' ' + t.slice(0, 4)}// { name, photoUrl, born, died, bio } — every field a plain string ('' = none)static personInfoOf = (&p) => {born = dateText(p.birthDate)place = textOr(p.birthPlace)let bornText = ''if (born != null && place != null) { bornText = 'Born ' + born + ' in ' + place } else if (born != null) { bornText = 'Born ' + born } else if (place != null) { bornText = 'Born in ' + place }died = dateText(p.deathDate)return { name = p.name != null ? p.name : '' photoUrl = photoUrlOf(p) born = bornText died = died != null ? 'Died ' + died : ''bio = textOr(p.biography) != null ? p.biography : '' }}// ---- the filmography --------------------------------------------------------------------------------------------// the credits, one row per title (a title twice — the migrated credit and TMDB's — is ONE row, roles merged), newest// first by the title's release ('YYYY-MM-DD', else its year), undated last; only public titles (no adult/unknown)static filmographyOf = (&p) => {byShow = {}order = []for (c of listOr(p.shows)) {if (c != null && c.show != null) {let role = textOr(c.character)let prev = byShow[c.show]if (prev == null) {byShow[c.show] = { role = role != null ? role : '' posterPath = textOr(c.posterPath) adult = c.adult == true }order.push(c.show)} else {let x = previf (role != null && !(' / ' + x.role + ' / ').includes(' / ' + role + ' / ')) { x.role = x.role == '' ? role : x.role + ' / ' + role }if (x.posterPath == null) { x.posterPath = textOr(c.posterPath) }if (c.adult == true) { x.adult = true }byShow[c.show] = x}}}list = []listed = {}for (sid of order) {let c = byShow[sid]// tracker#31: a credit on a merged tombstone is the keeper's (merge.hl repoints them; a keeper listed once)let s = liveShowOf(showById(sid))if (isPublicTitle(s) && !c.adult && listed[s.id] != true) {listed[s.id] = truelet y = showYear(s)let release = textOr(s.release)let key = release != null && release.length >= 4 ? release : (y != null ? y : '')let poster = posterUrlOf(s)if (poster == '/posters/none' && c.posterPath != null) { poster = tmdbImageBase + '/w92' + c.posterPath }list.push({ key = key id = s.id title = s.title year = y != null ? y : '' href = titlePath(s) posterUrl = posterrole = c.role typeLabel = typeWordOf(s) typeClass = typeClassOf(s) })}}out = []undated = []for (r of sortDesc(list)) { if (r.key == '') { undated.push(r) } else { out.push(r) } }for (r of undated) { out.push(r) }return out}// ---- the fetch ------------------------------------------------------------------------------------------------// person id → { items, pos, credits, added, requests, started, steps } while a fetch is under way (in memory: a restart// loses it — the titles stored so far are found again by TMDB id, nothing is duplicated)static jobs = {}// TMDB genre id → ours (our genres carry TMDB's id)static genreMap = { built = false ids = {} }static genreIdsOfTmdb = (&tmdbIds) => {if (!genreMap.built) {m = {}for (g of genresTable.find(null, null)) { if (g.tmdbId != null) { m['' + g.tmdbId] = g.id } }genreMap.ids = mgenreMap.built = true}out = []for (t of listOr(tmdbIds)) {let id = genreMap.ids['' + t]if (id != null && !out.includes(id)) { out.push(id) }}return out}// TMDB's combined_credits → one item per title (cast AND crew of the same title merged), TV + movies onlystatic creditItems = (&cc) => {byKey = {}order = []all = []if (cc != null) {for (c of listOr(cc.cast)) { all.push({ credit = c role = textOr(c.character) }) }for (c of listOr(cc.crew)) { all.push({ credit = c role = textOr(c.job) }) }}for (a of all) {let c = a.creditlet kind = c.media_typeif ((kind == 'tv' || kind == 'movie') && c.id != null && hlTypeName(c.id) == 'Number') {let k = kind + ':' + c.idlet known = byKey[k]if (known == null) {let name = kind == 'tv' ? textOr(c.name) : textOr(c.title)if (name != null) {byKey[k] = { kind = kind tmdbId = c.id title = name release = textOr(kind == 'tv' ? c.first_air_date : c.release_date)overview = textOr(c.overview) language = textOr(c.original_language) genreIds = listOr(c.genre_ids)posterPath = textOr(c.poster_path) adult = c.adult == true role = a.role != null ? a.role : '' }order.push(k)}} else if (a.role != null && !(' / ' + known.role + ' / ').includes(' / ' + a.role + ' / ')) {let x = knownx.role = x.role == '' ? a.role : x.role + ' / ' + a.rolebyKey[k] = x}}}out = []for (k of order) { out.push(byKey[k]) }return out}// the photo: w185 → storage/mpackdb/profiles/<oldId>.<ext> — { requests, image, tmdbProfile, failed }static syncProfile = (&p, profilePath) => {out = { requests = 0 image = p.image tmdbProfile = p.tmdbProfile failed = null }if (textOr(profilePath) == null || p.oldId == null) { return out }parts = profilePath.split('.')ext = parts[parts.length - 1].toLowerCase()if (ext != 'jpg' && ext != 'jpeg' && ext != 'png' && ext != 'webp') { return out }file = profilesDir + '/' + p.oldId + '.' + extif (exists(file) && p.tmdbProfile == profilePath && p.image == ext) { return out }out.requests = 1r = fetch(tmdbImageBase + '/w185' + profilePath, { headers = { 'user-agent' = 'tracker.worldapi.org (person page)' } timeoutMs = 20000 })if (r == null || r.status != 200 || r.body == null || r.body.length == 0) {out.failed = 'photo ' + profilePath + ': ' + (r == null ? 'no answer' : 'HTTP ' + r.status)return out}mkDir(profilesDir)writeFile(file, toBytes(r.body), 420)out.image = extout.tmdbProfile = profilePathreturn out}// step 1: TMDB's person + credits; the facts (a TMDB value that is empty never overwrites ours) and the photostatic startFill = (p) => {t0 = now()d = tmdbGet('/person/' + p.tmdbId + '?append_to_response=combined_credits')if (d.failed != null) { return { error = 'TMDB: ' + d.failed } }items = creditItems(d.combined_credits)photo = syncProfile(p, d.profile_path)x = pif (textOr(d.biography) != null) { x.biography = d.biography }if (textOr(d.birthday) != null) { x.birthDate = d.birthday }if (textOr(d.deathday) != null) { x.deathDate = d.deathday }if (textOr(d.place_of_birth) != null) { x.birthPlace = d.place_of_birth }if (textOr(x.imdbId) == null && textOr(d.imdb_id) != null) { x.imdbId = d.imdb_id }if (d.adult != null) { x.adult = d.adult == true }x.image = photo.imagex.tmdbProfile = photo.tmdbProfilex.updatedAt = now()if (personsTable.update(x.id, x) == null) { return { error = 'could not store the person: ' + personsTable.lastError() } }personTouched(x.id)if (photo.failed != null) { console.log('person fill: ' + p.name + ': ' + photo.failed) }jobs[p.id] = { items = items pos = 0 credits = [] added = 0 requests = 1 + photo.requests started = t0 steps = 1 tmdbMs = now() - t0 }return { done = false pos = 0 total = items.length }}// a title we don't have: the minimal record (shape: search.hl importTitle) → its id, or nullstatic addMinimalTitle = (&cr, &pid, pname) => {kindType = cr.kind == 'tv' ? 'series' : 'movie'rel = cr.releaseyr = rel != null && rel.length >= 4 ? toNumber(rel.slice(0, 4)) : nullslug = slugOf(cr.title, cr.tmdbId)record = {id = newId() title = cr.title summary = null tmdbSummary = cr.overview tmdbId = cr.tmdbIdlanguage = cr.language type = kindType release = rel year = yr image = nullurlSegment = slug episodesCount = null homepage = null imdbId = null seasonsCount = nullstatus = null tagline = null tvdbId = null tvmzId = null genres = genreIdsOfTmdb(cr.genreIds)cast = [{ person = pid character = cr.role actor = pname }] seasons = [] oldId = 'tmdb-' + cr.kind + '-' + cr.tmdbIdadult = cr.adult imported = now() minimal = true}// mission 060: its short id (shortids.hl — unique across shows and persons)record.shortId = claimShortId('show', record.id, record.oldId)if (showsTable.put(record) == null) {console.log('person fill: "' + cr.title + '" not stored: ' + showsTable.lastError())return null}indexShow(record)refreshCatalogShow(record.id)return record.id}// every next step: the next `fillBatch` titles; the last one links the credits and marks the person completestatic fillStep = (p, jobIn) => {job = jobIntotal = job.items.lengthlet end = job.pos + fillBatchif (end > total) { end = total }credits = job.creditslet added = job.addedpid = p.idpname = p.namelet i = job.poswhile (i < end) {let item = job.items[i]let sid = titleIdByTmdb(item.kind == 'tv' ? 'series' : 'movie', item.tmdbId)if (sid == null) {sid = addMinimalTitle(item, pid, pname)if (sid != null) { added = added + 1 }}if (sid != null) { credits.push({ show = sid character = item.role posterPath = item.posterPath adult = item.adult }) }i = i + 1}job.pos = endjob.credits = creditsjob.added = addedjob.steps = job.steps + 1jobs[pid] = jobif (end < total) { return { done = false pos = end total = total } }// done: the credits (+ the migrated ones TMDB no longer lists), completehave = {}all = []for (c of credits) { have[c.show] = true all.push(c) }for (c of listOr(p.shows)) { if (c != null && c.show != null && have[c.show] != true) { all.push(c) } }x = px.shows = allx.filmographyAt = now()x.updatedAt = x.filmographyAtif (personsTable.update(x.id, x) == null) { return { error = 'could not store the filmography: ' + personsTable.lastError() } }personTouched(x.id)personsTable.persist()showsTable.persist()jobs[pid] = nullconsole.log('person fill: ' + pname + ' (tmdb ' + p.tmdbId + ') credits=' + total + ' added=' + added + ' requests=' + job.requests + ' steps=' + job.steps + ' tmdbMs=' + job.tmdbMs + ' ms=' + (now() - job.started))return { done = true pos = total total = total added = added }}// one step for the person with this id: { done, pos, total } or { error }static personFillStep = (&personId) => {p = personsTable.fetch(personId)if (p == null) { return { error = 'no such person' } }if (isComplete(p)) { return { done = true } }if (p.tmdbId == null) { return { error = 'TMDB does not know this person' } }job = jobs[personId]if (job == null) { return startFill(p) }return fillStep(p, job)}// ---- people from a title's cast (tracker#26, details.hl completeStep) ------------------------------------------// TMDB person id → our person id, built once (15k people, one scan), kept up to date by castPersonId// (flat statics, written key by key like search.hl's haveTmdb: `let m = …ids` would copy the whole map — plain data// deep-copies on assignment)static personTmdbBuilt = { yes = false }static personTmdbIds = {}static personIdByTmdb = (&tmdbId) => {if (!personTmdbBuilt.yes) {for (p of personsTable.find(null, null)) { if (p.tmdbId != null) { personTmdbIds['' + p.tmdbId] = p.id } }personTmdbBuilt.yes = true}return personTmdbIds['' + tmdbId]}// a free person slug: "Maya-Rudolph", taken → "-2", … (the migrated people's form)static personSlugOf = (&name, &tmdbId) => {ensureSlugIndex()base = slugBaseOf(name, 'person-' + tmdbId)if (slugIndex.ids[base] == null) { return base }let n = 2while (slugIndex.ids[base + '-' + n] != null) { n = n + 1 }return base + '-' + n}// how many people castPersonId added since the start (details.hl counts a title's new people by the difference, tracker#28)static castPeopleAdded = { n = 0 }static castPeopleCount = () => { return castPeopleAdded.n }// our person for one cast entry of TMDB ({ tmdbId, name, character, gender, profile }) — the one we have by TMDB id, else a NEW// person (name, tmdbId, a free slug, this one credit, TMDB's profile path as `tmdbProfile` (tracker#37: the photo of /people and// the search); not complete: their page fetches the rest on the first visit like any migrated person). Answers the id, or null// when it could not be stored. Also a CREW person (tracker#28: `character` = the job, "Director").static castPersonId = (&c, &showId) => {have = personIdByTmdb(c.tmdbId)if (have != null) { return have }record = { id = newId() name = c.name tmdbId = c.tmdbId urlSegment = personSlugOf(c.name, c.tmdbId) oldId = 'tmdb-person-' + c.tmdbIdgender = c.gender image = null tmdbProfile = textOr(c.profile) adult = false birthDate = null birthPlace = null deathDate = null imdbId = nullshows = showId != null ? [{ show = showId character = c.character }] : [] added = now() }// mission 062 (tracker#27 for #26/#28): every person the app creates gets its short id at once (shortids.hl)record.shortId = claimShortId('person', record.id, record.oldId)if (personsTable.put(record) == null) {console.log('details: person "' + c.name + '" not stored: ' + personsTable.lastError())return null}personTmdbIds['' + c.tmdbId] = record.idslugIndex.ids[record.urlSegment] = record.idindexPerson(record)personTouched(record.id)castPeopleAdded.n = castPeopleAdded.n + 1return record.id}// ---- GUEST STARS (mission 062): a series' guests are NOT person records — details.hl stores them on the title ({ tmdbId, actor,// character, profile, episodes, guest }, `person` only when we have them already). 160k people (most of them one-episode guests)// cost 3.3 GB of RSS at boot (w061). A guest's link is `/person/tmdb/<tmdbId>?show=<show id>` (project.hl guestRoute): the FIRST// open creates the person (name + role from that title's guest entry; without one — an old link — TMDB's /person/<id>), with its// short id, then the person page fills it like any other.static guestOf = (&showId, &tmdbId) => {s = showId != null && hlTypeName(showId) == 'String' && showId != '' ? showsTable.fetch(showId) : nullif (s == null) { return null }for (c of listOr(s.cast)) { if (c != null && c.tmdbId == tmdbId && textOr(c.actor) != null) { return c } }return null}// our person for this TMDB person (created now when we have none) → { id, created } or { error }static guestPersonId = (tmdbId, showId) => {have = personIdByTmdb(tmdbId)if (have != null) { return { id = have created = false } }g = guestOf(showId, tmdbId)let c = nulllet inShow = nullif (g != null) {c = { tmdbId = tmdbId name = g.actor character = g.character gender = null }inShow = showId} else {d = tmdbGet('/person/' + tmdbId)if (d.failed != null) { return { error = 'TMDB: ' + d.failed } }if (textOr(d.name) == null || d.adult == true) { return { error = 'no such person' } }c = { tmdbId = tmdbId name = d.name character = null gender = d.gender }}pid = castPersonId(c, inShow)if (pid == null) { return { error = 'could not store the person' } }personsTable.persist()console.log('guest opened: ' + c.name + ' (tmdb ' + tmdbId + ') → new person ' + pid + (inShow != null ? ' from show ' + inShow : ' from TMDB'))return { id = pid created = true }}// ---- /people (tracker#37, mission 032): everyone we have, the one updated last first ------------------------------------------// Creator: "/people just lists persons by last one updated with poster name and pagination". LAST UPDATED = the newest of// `updatedAt` (stamped by the person page's fetch, startFill / fillStep), `filmographyAt` and `added` (a person the app created);// the migrated people with none of them come last (newest stored first). No adult filter (tracker#32's /people had none either).// A CACHED VIEW like tracker#30's list caches (mission 025): the order is built ONCE at boot (project.hl warmPeopleView: one pass// over the people + one sort, and the cast photos below — 105k people, 3–4 s on the live copy), a page only slices its 24 ids and// fetches those people (~15 ms server time, any page). Kept current without a rebuild: every person the app creates or updates is// `personTouched` → `recent`; the next page read moves them to the front (one pass over the ids, ~50 ms, only after a change).static peopleView = { built = false ids = [] recent = [] castProfiles = 0 }static updatedKeyOf = (&p) => {let k = p.updatedAt != null ? p.updatedAt : 0if (p.filmographyAt != null && p.filmographyAt > k) { k = p.filmographyAt }if (p.added != null && p.added > k) { k = p.added }return k}static warmPeopleView = () => {if (peopleView.built) { return peopleView.ids.length }peopleView.castProfiles = castProfilesOf()stored = []for (p of personsTable.iterate(null, null)) { stored.push({ id = p.id key = updatedKeyOf(p) }) }// newest stored first: the table is in the order the people were stored, so the list is nearly sorted already (util.hl sortDesc// keeps runs) and equal keys (the migrated people: 0) end newest stored firstnewestFirst = []let i = stored.length - 1while (i >= 0) { newestFirst.push(stored[i]) i = i - 1 }ids = []for (x of sortDesc(newestFirst)) { ids.push(x.id) }peopleView.ids = idspeopleView.built = truereturn ids.length}// a person was created or updated: first in /people from the next page read onstatic personTouched = (&id) => {if (peopleView.built && id != null) { peopleView.recent.push(id) }return null}static peopleViewCurrent = () => {if (!peopleView.built) { warmPeopleView() }if (peopleView.recent.length == 0) { return true }seen = {}out = []let i = peopleView.recent.length - 1while (i >= 0) {let id = peopleView.recent[i]if (seen[id] != true) { seen[id] = true out.push(id) }i = i - 1}for (id of peopleView.ids) { if (seen[id] != true) { out.push(id) } }peopleView.ids = outpeopleView.recent = []return true}static peopleViewStats = () => { return { people = peopleView.ids.length castProfiles = peopleView.castProfiles } }static peopleCount = () => {peopleViewCurrent()return peopleView.ids.length}// a person's photo for a tile or a search row: our file (photoUrlOf), else TMDB's (`tmdbProfile`, w185 — like the filmography's// TMDB posters), else TMDB's from a title's cast entry (castProfiles), else the placeholderstatic personPhotoOf = (&p) => {local = photoUrlOf(p)if (local != '') { return local }path = textOr(p.tmdbProfile) != null ? p.tmdbProfile : castProfiles[p.id]return path != null ? tmdbImageBase + '/w185' + path : '/profiles/none'}// person id → TMDB's profile path from a title's cast or crew entry (credits.hl stores `profile` there: cast since tracker#28, crew// since tracker#37) — for the people the credits job created before castPersonId kept it as `tmdbProfile` (live copy 2026-10-04:// 51,922 of 105,018 people; the crew entries had none yet). Built at boot with the view (one pass over the titles, ~1–3 s). IN// MEMORY, not written to the people: 51,907 record updates took 511 s at a start (mission 032, measured on the live copy).// (Flat static written key by key, like the maps above.)static castProfiles = {}static castProfilesOf = () => {let n = 0// iterate, not find: one title decoded at a timefor (s of showsTable.iterate(null, null)) {for (c of listOr(s.cast)) {if (c != null && c.person != null && textOr(c.profile) != null && castProfiles[c.person] == null) {castProfiles[c.person] = c.profilen = n + 1}}for (c of listOr(s.crew)) {if (c != null && c.person != null && textOr(c.profile) != null && castProfiles[c.person] == null) {castProfiles[c.person] = c.profilen = n + 1}}}return n}// one page: { page, pages, tiles } — a tile like the title grids' (components/tilelist.hl): photo, name, no yearstatic peoplePage = (&raw) => {peopleViewCurrent()pages = pagesOf(peopleView.ids.length)page = pageNumberOf(raw, pages)tiles = []for (id of peopleView.ids.slice((page - 1) * pageSize, page * pageSize)) {let p = personsTable.fetch(id)if (p != null && p.urlSegment != null) {tiles.push({ href = '/person/' + p.urlSegment posterUrl = personPhotoOf(p) title = p.name != null ? '' + p.name : '' year = '' })}}return { page = page pages = pages tiles = tiles }}
Branches
- mainmain branch
Latest commits
- 4ecc67b2tracker: STATUS/LOG for the t38 + t40 merge (gates 374/0, 32/0, 53/0, 229/0, 26/0, check-theme 0; live copy checks)mre
- e5945d2fMerge t40 (tracker#40 curated franchises, Franchises menu, superseded collections) into main: deploy.sh lists all six gates (browser, kinds, franchises, pager, franchiseseed, check-theme); search.hl keeps #37's personPhotoOf + #40's importWithCredits; pager gate runs with TRACKER_FRANCHISE_SEED=0; gates 374/0, 32/0, 53/0, 229/0, 26/0, check-theme 0mre
- b638e99dMerge t38 (tracker#38 pagination, #35 Returning/Airing label) into main: LOG/STATUS keep both sides; pager gate follows #37's /people (everyone, last updated first: seed updatedAt); gates pager 229/0, browser 374/0mre
- f8ffa4dbtracker: report 032 + The Remaining + #39mre
- 7565a863tracker: LOG timemre
- b10f00c8tracker#39: double episodes — migrated episodes whose TMDB id TMDB replaced are adopted by their number in the sync (old id -> migratedTmdbId); merge.hl step 3 merges each season's doubles at start (keeper: most watches > synced > first; watches moved/parked; tombstones into mergedEpisodes, nothing deleted); tools/count-duplicate-episodes.hl; gate fixture + paths-m039; live copy 850 -> 0 in 64 s; gates 373/0, 32/0, 52/0mre
- 8f1d4542tracker#40: superseded collections — a TMDB collection timeline whose titles are all in one curated timeline is hidden (supersededBy; kept: own page + editor finder), set by the collection seed when it makes one and by the curated build (lifted when the cover is gone); partly covered ones join that franchise; no second widget (First Contact: only Star Trek — Prime); gate franchiseseed 26/0, browser 365/0, kinds 32/0, franchises 53/0, check-theme 0; real copy 24 supersededmre
- 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
- 7d4b293dtracker#40: "Franchises" in the main menu (desktop header after People, phone sidebar) → /franchises, marked on franchise and timeline pages; gates 365/0, 32/0, 53/0, 24/0, check-theme 0mre
- 8751adb8tracker: report 032mre
- 9bce1f65tracker mission 032: STATUS gate files + the hour-boundary flakemre
- 718bfb89tracker#37 (mission 032): /people = everyone, last updated first (updatedAt stamped by the person fill; view built at boot, touched people first at once), photo + name tiles (person colour) with the /movies pagination, /people/<letter> removed; photo = our file, tmdbProfile, a cast/crew entry's profile (in-memory map at boot), else the new 'no photo' placeholder; new cast/crew/created_by people keep tmdbProfile; search people rows with the photo; /settings = the heading only; util.hl sortDesc starts from sorted runs (same result, 105k: 1.6 s -> 0.15 s); gates 369/0, 32/0, 52/0, check-theme 0; README/STATUS/LOGmre
- 93dfb0batracker#40 (mission 034): the curated franchises — data/franchises.json (17 franchises, 31 timelines, 285 TMDB titles, movies + series, in-universe/release order, 12 TMDB collections attached); lib/franchiseseed.hl + jobs.hl franchiseSeedTick (last start job, imports missing titles via details.hl importWithCredits = the search's Add, one per step paced, then one build; franchiseseed.db: editor changes win, the creator's same-name franchise adopted / timeline left alone, 404 remembered, resumable, idempotent); timeline heads 'N titles · in-universe order' (orderKind) and wrap on a phone; series pages show the widget; new gate tests/franchiseseed.mjs (5th in deploy.sh), the others run with TRACKER_FRANCHISE_SEED=0; gates 365/0, 32/0, 52/0, 24/0, check-theme 0; real copy 196 imported, 0 failed, 7 min, restart unchanged=31mre
- 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