tracker
All repositories: gitoria
25.0 KB
// franchises.hl — FRANCHISES AND TIMELINES (tracker.worldapi.org#19, mission 066). The creator: "we have franchises, and// timelines, and some franchises have multiple timelines ... make them sortable by timeline or release date at shows the// release date of the last episode counts. in a show or movie page we show a franchise widget with < Prequel | TimelineName// | Sequel > and if the timeline is part of a franchise that franchise is above it and when you click on it you get the// franchise index."//// franchisesTable pk @id index !slug { name, slug, shortId (null: not given out yet), summary, created }// timelinesTable pk @id index !slug { name, slug, franchise (@id | null), order [show @id …], source ('tmdb' |// 'editor'), tmdbCollection (TMDB collection id | null), created }// collectionChecksTable pk = the movie's show @id { collection (TMDB id | null), why, at } — the seed's progress// storage/mpackdb/{franchises,timelines,collectionchecks}.db (new tables; nothing in shows.db changes)//// A TITLE MAY BE IN SEVERAL TIMELINES (Star Trek Prime and Kelvin, or a TMDB collection and a hand-made one): `byShow` (show// @id → [timeline @id]) is built once at start (`ensureFranchises`, one scan of the timelines) and kept up to date by every// edit — the show page's widget reads only its own timelines, never all of them.//// THE ORDER of a timeline is its in-world order (`order`, editable). The pages can sort it by RELEASE instead: a movie by its// `release`, a SERIES by the release date of its LAST episode (the newest one already out; none out yet → the newest// announced one; no episode dates at all → the series' own `release`). Titles without any date go last.//// PUBLIC LIKE THE OTHER LISTS (mission 054): the pages and the widget's prequel/sequel links show only titles with// `adult == false` (shows.hl isPublicTitle); the order keeps them, they are just skipped.//// THE SEED (TMDB movie collections): every movie with a tmdbId (adult ones skipped) is asked once// GET <TMDB>/movie/<tmdbId> → belongs_to_collection { id, name } (or null)// GET <TMDB>/collection/<id> → parts [{ id, title, release_date, adult }] — once per collection// A collection with at least TWO of our (non-adult) movies becomes one timeline (no franchise), its parts in release order;// every part we have is marked checked at once (no own details request). A collection with fewer is remembered (checks) and// gives no timeline (a widget with both ends disabled says nothing). Run in the app like the adult backfill (project.hl// `collectionTick`, one movie per step, ≤ 40 TMDB requests / 10 s, waits while the daily sync or the adult backfill runs),// RESUMES after a restart (the queue = the movies without a check row). A hand-edited timeline is never touched by it.//// ONE PROCESS PER STORAGE (hybriel#21): the tables are opened here, in the app's process only.import { MPackDB } from 'hl:mpackdb'import { env } from 'hl:proc'import { now, timestamp } from 'hl:time'import { randomBytes } from 'hl:crypto'import { showsTable, storageDir, showById, seasonsOfShow, episodesOfSeason, titleWithYear, posterUrlOf, isPublicTitle, titlePath } from './shows.hl'import { userOfSession } from './users.hl'import { tmdbGet } from './tmdbsync.hl'import { normalizeSlugText, dbSearch } from './search.hl'static franchisesTable = new MPackDB(file = storageDir + '/franchises.db', primaryKey = '@id', indexes = ['!slug'])static timelinesTable = new MPackDB(file = storageDir + '/timelines.db', primaryKey = '@id', indexes = ['!slug'])static collectionChecksTable = new MPackDB(file = storageDir + '/collectionchecks.db', primaryKey = '@id', indexes = [])static newId = () => { return randomBytes(8, 'hex') }static todayStr = () => { return timestamp(now()).slice(0, 10) }static dateOr = (&v) => { return v != null && hlTypeName(v) == 'String' && v.length >= 4 ? v : '' }static textOr = (&v) => { return v != null && hlTypeName(v) == 'String' && v.trim() != '' ? v.trim() : null }// ---- who may edit: the creator (ident short id az5b2); TRACKER_EDITORS = a comma list of ident ids (the gate sets its own) ----static editorIds = () => {let v = env('TRACKER_EDITORS')let raw = v != null && v.trim() != '' ? v : 'az5b2'let out = []for (p of raw.split(',')) { if (p.trim() != '') { out.push(p.trim()) } }return out}static editors = editorIds()static isEditorSession = (&session) => {if (session == null || hlTypeName(session) != 'Instance') { return false }let u = userOfSession(session)return u != null && u.identity != null && editors.includes(u.identity)}// ---- slugs: "Star Trek" → "Star-Trek" (the shows' urlSegment form: letters and digits, everything else one '-') ----------static slugChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'static slugBase = (&name) => {let t = normalizeSlugText(name)let out = ''let dash = falselet i = 0while (i < t.length) {let c = t[i]if (slugChars.includes(c)) {if (dash && out != '') { out = out + '-' }out = out + cdash = false} else { dash = true }i = i + 1}if (out.length > 80) { out = out.slice(0, 80) }return out == '' ? 'timeline' : out}static firstOf = (&list) => { return list != null && list.length > 0 ? list[0] : null }// a list's index of x, -1 when it is not in it (hand-written: list helpers beyond includes/push are not trusted, hybriel#1)static posOf = (&list, &x) => {let i = 0while (i < list.length) {if (list[i] == x) { return i }i = i + 1}return -1}static freeSlug = (&table, &name) => {let base = slugBase(name)// 'edit' is the editor's own address (/franchises/edit)if (firstOf(table.find('slug', base)) == null && base != 'edit') { return base }let n = 2while (firstOf(table.find('slug', base + '-' + n)) != null) { n = n + 1 }return base + '-' + n}static franchiseBySlug = (&slug) => {if (slug == null || hlTypeName(slug) != 'String' || slug == '') { return null }return firstOf(franchisesTable.find('slug', slug))}static timelineBySlug = (&slug) => {if (slug == null || hlTypeName(slug) != 'String' || slug == '') { return null }return firstOf(timelinesTable.find('slug', slug))}static franchiseById = (&id) => { return id == null || hlTypeName(id) != 'String' || id == '' ? null : franchisesTable.fetch(id) }static timelineById = (&id) => { return id == null || hlTypeName(id) != 'String' || id == '' ? null : timelinesTable.fetch(id) }// ---- byShow: show @id → [timeline @id] ------------------------------------------------------------------------------------static byShow = {}static state = { built = false timelines = 0 ms = 0 }static indexAdd = (&showId, &timelineId) => {let l = byShow[showId]if (l == null) { l = [] }if (!l.includes(timelineId)) { l.push(timelineId) }byShow[showId] = lreturn true}static indexRemove = (&showId, &timelineId) => {let l = byShow[showId]if (l == null) { return true }let out = []for (x of l) { if (x != timelineId) { out.push(x) } }byShow[showId] = outreturn true}static ensureFranchises = () => {if (state.built) { return true }state.built = truelet t0 = now()let n = 0for (t of timelinesTable.find(null, null)) {n = n + 1if (t.order != null) { for (sid of t.order) { indexAdd(sid, t.id) } }}state.timelines = nstate.ms = now() - t0console.log('franchises: ' + n + ' timelines, ' + franchisesTable.count() + ' franchises (' + state.ms + ' ms)')return true}static timelineIdsOfShow = (&showId) => {ensureFranchises()let l = byShow[showId]return l != null ? l : []}// ---- a title as the pages list it ------------------------------------------------------------------------------------------// the date the release sort uses: a movie's release; a series' LAST episode (newest out ≤ today, else newest announced, else// its own release)static releaseKeyOf = (&show) => {if (show.type != 'series') { return dateOr(show.release) }let today = todayStr()let lastOut = ''let lastAny = ''for (s of seasonsOfShow(show)) {for (e of episodesOfSeason(s)) {let d = dateOr(e.release)if (d != '') {if (d > lastAny) { lastAny = d }if (d <= today && d > lastOut) { lastOut = d }}}}if (lastOut != '') { return lastOut }if (lastAny != '') { return lastAny }return dateOr(show.release)}static itemOf = (&show, pos) => {let series = show.type == 'series'let d = releaseKeyOf(show)let label = d == '' ? 'no date' : (series ? 'last episode ' + d : d)return { id = show.id pos = pos title = titleWithYear(show) href = titlePath(show) posterUrl = posterUrlOf(show)kind = series ? 'Series' : 'Movie' date = d dateLabel = label }}// the public titles of a timeline, in its order (`pos` = the place in the in-world order, 1-based)static itemsOf = (&t) => {let out = []let pos = 0if (t.order == null) { return out }for (sid of t.order) {let s = showById(sid)if (s != null && isPublicTitle(s)) {pos = pos + 1out.push(itemOf(s, pos))}}return out}// release order: oldest first, no date last; equal dates keep the timeline order (a stable insertion)static byReleaseOf = (&items) => {let out = []for (it of items) {let next = []let done = falsefor (x of out) {if (!done && it.date != '' && (x.date == '' || it.date < x.date)) { next.push(it) done = true }next.push(x)}if (!done) { next.push(it) }out = next}return out}static blockOf = (&t) => {let items = itemsOf(t)return { id = t.id name = t.name slug = t.slug href = '/timelines/' + t.slug items = items byRelease = byReleaseOf(items)countText = items.length == 1 ? '1 title' : items.length + ' titles' first = items.length > 0 ? byReleaseOf(items)[0].date : '' }}// ---- the pages -------------------------------------------------------------------------------------------------------------// /franchises: every franchise (name, how many timelines), then the timelines WITHOUT one (TMDB's collections, by name)static byNameOf = (&rows) => {let out = []for (r of rows) {let next = []let done = falselet k = r.sortNamefor (x of out) {if (!done && k < x.sortName) { next.push(r) done = true }next.push(x)}if (!done) { next.push(r) }out = next}return out}static franchiseIndex = () => {let counts = {}let loose = []for (t of timelinesTable.find(null, null)) {let n = t.order != null ? t.order.length : 0if (t.franchise != null && t.franchise != '') { counts[t.franchise] = (counts[t.franchise] != null ? counts[t.franchise] : 0) + 1 }else if (n > 0) { loose.push({ id = t.id name = t.name href = '/timelines/' + t.slug countText = n == 1 ? '1 title' : n + ' titles' sortName = ('' + t.name).toLowerCase() }) }}let fr = []for (f of franchisesTable.find(null, null)) {let c = counts[f.id] != null ? counts[f.id] : 0fr.push({ id = f.id name = f.name href = '/franchises/' + f.slug summary = f.summary != null ? f.summary : ''countText = c == 1 ? '1 timeline' : c + ' timelines' sortName = ('' + f.name).toLowerCase() })}return { franchises = byNameOf(fr) timelines = byNameOf(loose) }}// /franchises/<slug>: the franchise and its timelines (oldest first by their first release) — or nullstatic franchisePage = (slug) => {let f = franchiseBySlug(slug)if (f == null) { return null }let blocks = []for (t of timelinesTable.find(null, null)) {if (t.franchise == f.id) {let b = blockOf(t)let next = []let done = falsefor (x of blocks) {if (!done && b.first != '' && (x.first == '' || b.first < x.first)) { next.push(b) done = true }next.push(x)}if (!done) { next.push(b) }blocks = next}}return { kind = 'franchise' name = f.name slug = f.slug summary = f.summary != null ? f.summary : '' franchiseName = '' franchiseHref = '' blocks = blocks editHref = '' }}// /timelines/<slug>: one timeline, its franchise above it — or nullstatic timelinePage = (slug) => {let t = timelineBySlug(slug)if (t == null) { return null }let f = franchiseById(t.franchise)return { kind = 'timeline' name = t.name slug = t.slug summary = '' franchiseName = f != null ? f.name : '' franchiseHref = f != null ? '/franchises/' + f.slug : ''blocks = [blockOf(t)] editHref = '/timelines/' + t.slug + '/edit' }}// ---- the widget on a show / movie page: one per timeline the title is in ----------------------------------------------------// { id, name, href, hasFranchise, franchiseName, franchiseHref, hasPrev, noPrev, prevHref, prevTitle, hasNext, noNext, … }:// prequel / sequel = the nearest PUBLIC title before / after this one in the timeline's orderstatic widgetsOf = (&showId) => {let out = []if (showId == null) { return out }for (tid of timelineIdsOfShow(showId)) {let t = timelinesTable.fetch(tid)if (t != null && t.order != null && t.order.includes(showId)) {let at = posOf(t.order, showId)let prev = nulllet i = at - 1while (prev == null && i >= 0) {let s = showById(t.order[i])if (s != null && isPublicTitle(s)) { prev = s }i = i - 1}let next = nulllet j = at + 1while (next == null && j < t.order.length) {let s = showById(t.order[j])if (s != null && isPublicTitle(s)) { next = s }j = j + 1}let f = franchiseById(t.franchise)out.push({ id = t.id name = t.name href = '/timelines/' + t.slughasFranchise = f != null franchiseName = f != null ? f.name : '' franchiseHref = f != null ? '/franchises/' + f.slug : ''hasPrev = prev != null noPrev = prev == null prevHref = prev != null ? titlePath(prev) : '' prevTitle = prev != null ? titleWithYear(prev) : ''hasNext = next != null noNext = next == null nextHref = next != null ? titlePath(next) : '' nextTitle = next != null ? titleWithYear(next) : '' })}}return out}// ---- the editor (the creator only — every caller checks isEditorSession first) ----------------------------------------------static createFranchise = (name, summary) => {let n = textOr(name)if (n == null || n.length > 120) { return { error = 'a franchise needs a name (at most 120 characters)' } }let s = textOr(summary)let rec = { id = newId() name = n slug = freeSlug(franchisesTable, n) shortId = null summary = s != null ? s : '' created = now() }if (franchisesTable.put(rec) == null) { return { error = 'not stored: ' + franchisesTable.lastError() } }franchisesTable.persist()console.log('franchises: new franchise "' + n + '" /franchises/' + rec.slug)return { ok = true slug = rec.slug id = rec.id }}// a new timeline (the editor's, or the seed's with its collection id)static putTimeline = (name, &franchiseId, order, source, &collection) => {let rec = { id = newId() name = name slug = freeSlug(timelinesTable, name) franchise = franchiseId order = order source = sourcetmdbCollection = collection created = now() }if (timelinesTable.put(rec) == null) { return null }for (sid of order) { indexAdd(sid, rec.id) }return rec}static createTimeline = (&name) => {let n = textOr(name)if (n == null || n.length > 120) { return { error = 'a timeline needs a name (at most 120 characters)' } }let rec = putTimeline(n, null, [], 'editor', null)if (rec == null) { return { error = 'not stored: ' + timelinesTable.lastError() } }timelinesTable.persist()console.log('franchises: new timeline "' + n + '" /timelines/' + rec.slug)return { ok = true slug = rec.slug id = rec.id }}// the editor's view of one timeline: every title in its order (adult ones too — the creator sees what is there)static editRowsOf = (&t) => {let out = []if (t == null || t.order == null) { return out }let i = 0let last = t.order.length - 1for (sid of t.order) {let s = showById(sid)if (s != null) {out.push({ id = s.id pos = i + 1 title = titleWithYear(s) posterUrl = posterUrlOf(s) typeLabel = s.type == 'series' ? 'Series' : 'Movie'upClass = i == 0 ? 'quiet mv off' : 'quiet mv' downClass = i == last ? 'quiet mv off' : 'quiet mv' })}i = i + 1}return out}static chipsOf = (&t) => {let out = [{ id = '' name = 'None' cls = t == null || t.franchise == null || t.franchise == '' ? 'chip on' : 'chip' }]let list = []for (f of franchisesTable.find(null, null)) { list.push({ id = f.id name = f.name cls = t != null && t.franchise == f.id ? 'chip on' : 'chip' sortName = ('' + f.name).toLowerCase() }) }for (c of byNameOf(list)) { out.push(c) }return out}static editPageOf = (slug) => {let t = timelineBySlug(slug)if (t == null) { return { found = false id = '' name = '' slug = '' viewHref = '' rows = [] chips = [] } }return { found = true id = t.id name = t.name slug = t.slug viewHref = '/timelines/' + t.slug rows = editRowsOf(t) chips = chipsOf(t) }}static storeOrder = (t, order) => {let old = t.order != null ? t.order : []let rec = trec.order = orderif (timelinesTable.update(rec.id, rec) == null) { return { error = 'not stored: ' + timelinesTable.lastError() } }timelinesTable.persist()for (sid of old) { if (!order.includes(sid)) { indexRemove(sid, rec.id) } }for (sid of order) { indexAdd(sid, rec.id) }return { ok = true rows = editRowsOf(rec) }}static addToTimeline = (&timelineId, &showId) => {let t = timelineById(timelineId)if (t == null) { return { error = 'no such timeline' } }if (showById(showId) == null) { return { error = 'no such title' } }let order = []for (sid of t.order) { order.push(sid) }if (order.includes(showId)) { return { ok = true rows = editRowsOf(t) } }order.push(showId)return storeOrder(t, order)}static removeFromTimeline = (&timelineId, &showId) => {let t = timelineById(timelineId)if (t == null) { return { error = 'no such timeline' } }let order = []for (sid of t.order) { if (sid != showId) { order.push(sid) } }return storeOrder(t, order)}// one place up (delta -1) or down (+1); at an end nothing changesstatic moveInTimeline = (&timelineId, &showId, &delta) => {let t = timelineById(timelineId)if (t == null) { return { error = 'no such timeline' } }let at = posOf(t.order, showId)let to = at + (delta < 0 ? -1 : 1)if (at < 0 || to < 0 || to >= t.order.length) { return { ok = true rows = editRowsOf(t) } }let order = []let i = 0while (i < t.order.length) {if (i == at) { order.push(t.order[to]) } else if (i == to) { order.push(t.order[at]) } else { order.push(t.order[i]) }i = i + 1}return storeOrder(t, order)}static setTimelineFranchise = (&timelineId, franchiseId) => {let t = timelineById(timelineId)if (t == null) { return { error = 'no such timeline' } }let fid = nullif (franchiseId != null && franchiseId != '') {if (franchiseById(franchiseId) == null) { return { error = 'no such franchise' } }fid = franchiseId}let rec = trec.franchise = fidif (timelinesTable.update(rec.id, rec) == null) { return { error = 'not stored: ' + timelinesTable.lastError() } }timelinesTable.persist()return { ok = true chips = chipsOf(rec) }}// the editor's title search: our database (search.hl's index), the titles not in the timeline yetstatic editSearch = (&timelineId, text) => {let t = timelineById(timelineId)let have = t != null && t.order != null ? t.order : []let out = []let r = dbSearch(text != null ? '' + text : '')for (x of r.titles) {let s = showById(x.id)if (s != null && !have.includes(s.id)) { out.push({ id = s.id title = titleWithYear(s) posterUrl = posterUrlOf(s) typeLabel = s.type == 'series' ? 'Series' : 'Movie' }) }}return { text = text rows = out note = r.tooShort ? 'Type at least 2 letters.' : (out.length == 0 ? 'Nothing found.' : '') }}// the editor's timeline finder: by name, at most 30static findTimelines = (&text) => {let q = text != null ? ('' + text).trim().toLowerCase() : ''let out = []if (q.length < 2) { return out }for (t of timelinesTable.find(null, null)) {if (out.length < 30 && ('' + t.name).toLowerCase().includes(q)) {let n = t.order != null ? t.order.length : 0out.push({ id = t.id name = t.name href = '/timelines/' + t.slug + '/edit' countText = n == 1 ? '1 title' : n + ' titles' })}}return out}// ---- THE SEED from TMDB's movie collections ----------------------------------------------------------------------------------// the queue is a plain static list filled in place (a static cannot be reassigned; read through a field it was copied on every// access — shortids.hl), worked off from `seed.pos`static seedQueue = []static checkedShows = {}static collectionsDone = {}static movieByTmdb = {}static seedTries = {}static seed = { built = false pos = 0 movies = 0 checked = 0 requests = 0 timelines = 0 titles = 0 small = 0 noCollection = 0 notFound = 0 errors = 0 retries = 0 }static markChecked = (&showId, collection, why) => {if (checkedShows[showId] == true) { return true }checkedShows[showId] = trueseed.checked = seed.checked + 1if (collectionChecksTable.put({ id = showId collection = collection why = why at = now() }) == null) {console.log('collections: check of ' + showId + ' not stored: ' + collectionChecksTable.lastError())}return true}static seedPrepare = () => {if (seed.built) { return seedQueue.length }seed.built = trueensureFranchises()for (c of collectionChecksTable.find(null, null)) {checkedShows[c.id] = trueif (c.collection != null) { collectionsDone['' + c.collection] = true }}for (t of timelinesTable.find(null, null)) { if (t.tmdbCollection != null) { collectionsDone['' + t.tmdbCollection] = true } }for (s of showsTable.find(null, null)) {if (s.type == 'movie' && s.tmdbId != null && s.adult != true) {movieByTmdb['' + s.tmdbId] = s.idif (checkedShows[s.id] != true) { seedQueue.push(s.id) }}}seed.movies = seedQueue.lengthreturn seedQueue.length}static seedLeft = () => { return seedQueue.length - seed.pos }static seedStats = () => { return seed }static seedPersist = () => {collectionChecksTable.persist()timelinesTable.persist()return true}// a passing failure goes to the back of the queue, at most 2 more tries per startstatic seedRetry = (&showId) => {let n = seedTries[showId] != null ? seedTries[showId] : 0if (n >= 2) { return false }seedTries[showId] = n + 1seedQueue.push(showId)seed.retries = seed.retries + 1return true}// ONE movie: → { requests (TMDB API requests made: what the pause counts), done, error }static seedStep = () => {let out = { requests = 0 done = false error = '' }seedPrepare()if (seed.pos >= seedQueue.length) { out.done = true return out }let id = seedQueue[seed.pos]seed.pos = seed.pos + 1if (checkedShows[id] == true) { return out }let show = showsTable.fetch(id)if (show == null || show.adult == true || show.tmdbId == null) { return out }out.requests = 1seed.requests = seed.requests + 1let d = tmdbGet('/movie/' + show.tmdbId)if (d.failed != null) {seed.errors = seed.errors + 1out.error = show.title + ': ' + d.failedif (d.status == 404) { seed.notFound = seed.notFound + 1 markChecked(id, null, 'TMDB 404') } else { seedRetry(id) }return out}let c = d.belongs_to_collectionif (c == null || c.id == null) {seed.noCollection = seed.noCollection + 1markChecked(id, null, 'no collection')return out}let cid = '' + c.idif (collectionsDone[cid] == true) { markChecked(id, c.id, 'collection known') return out }out.requests = 2seed.requests = seed.requests + 1let coll = tmdbGet('/collection/' + cid)if (coll.failed != null || coll.parts == null) {seed.errors = seed.errors + 1out.error = show.title + ': collection ' + cid + ': ' + (coll.failed != null ? coll.failed : 'no parts')if (coll.status == 404) { markChecked(id, c.id, 'collection 404') collectionsDone[cid] = true } else { seedRetry(id) }return out}// the parts in release order (no date last), only the non-adult ones we havelet parts = []for (p of coll.parts) {let next = []let done = falselet pd = dateOr(p.release_date)for (x of parts) {if (!done && pd != '' && (x.d == '' || pd < x.d)) { next.push({ p = p d = pd }) done = true }next.push(x)}if (!done) { next.push({ p = p d = pd }) }parts = next}let order = []for (x of parts) {let sid = x.p.adult == true ? null : movieByTmdb['' + x.p.id]if (sid != null && !order.includes(sid)) { order.push(sid) }}collectionsDone[cid] = trueif (order.length >= 2) {let name = textOr(coll.name) != null ? coll.name.trim() : (textOr(c.name) != null ? c.name.trim() : 'Collection ' + cid)let rec = putTimeline(name, null, order, 'tmdb', c.id)if (rec == null) {seed.errors = seed.errors + 1out.error = 'timeline "' + name + '" not stored: ' + timelinesTable.lastError()} else {seed.timelines = seed.timelines + 1seed.titles = seed.titles + order.length}} else { seed.small = seed.small + 1 }for (sid of order) { markChecked(sid, c.id, 'collection') }markChecked(id, c.id, 'collection')return out}static seedLine = () => {return seed.pos + '/' + seedQueue.length + ' movies, checked=' + seed.checked + ' timelines=' + seed.timelines + ' titles=' + seed.titles + ' small=' + seed.small+ ' noCollection=' + seed.noCollection + ' notFound=' + seed.notFound + ' requests=' + seed.requests + ' errors=' + seed.errors + ' retries=' + seed.retries}
Branches
- mainmain branch
Latest commits
- 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
- 25a50bc4tracker#26 (mission 059): titles from a filmography are completed — on open (skeleton, step-wise face showComplete, no reload) and by the in-app details repair (resumes, TMDB-paced, series in parts); cast from TMDB credits; gate 231, tests/realdata-026.mjs, README + STATUSmre
- 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