tracker
All repositories: gitoria
26.9 KB
// details.hl — A TITLE'S FULL DETAILS (tracker.worldapi.org#26). Creator: "when i go to an artist and it loads the bibliography// those loaded movies and series miss most information and it stays that way when i click on them".//// The person page's fill (people.hl, #16) adds every title we don't have as a MINIMAL record — title, year, overview, genres,// type, tmdbId, adult, this one person as cast; no poster, no seasons, no external ids, no homepage/tagline/status// (`minimal = true`). A search import (search.hl importTitle) is full except for the cast. Here a minimal title gets what a// full import has, plus its cast://// GET <TMDB>/movie/<tmdbId>?append_to_response=external_ids,credits a movie// GET <TMDB>/tv/<tmdbId>?append_to_response=external_ids,aggregate_credits a series (cast of ALL seasons, roles merged)// then tmdbsync.hl `syncShowPart(id, <that answer>, …)` — the daily sync's work, without asking for the details again, IN// STEPS (see `jobs` below): a series' seasons + episodes (`season/1,…`), then the poster (w342), missing imdb/tvdb ids,// TVmaze lookup + its episode merge (#15); a movie its imdb id + poster//// Stored (`withDetailsFields`, shared with the daily sync since tracker#18): title, overview → `tmdbSummary` (`summary` is the// creator's own text, never written), homepage, tagline, status, genres (by TMDB id, when// TMDB gives any), release/year/language when we have none, `adult`, the CAST (TMDB's first `castLimit`, in TMDB's order;// a person we have by TMDB id is linked, a new one is added — people.hl castPersonId; cast entries we had and TMDB does not// list are kept after them). Then `detailsAt` (ms) = COMPLETE, never asked again (the daily sync keeps a FOLLOWED title fresh).// TMDB 404 → `detailsCheck = 'TMDB 404'` (never asked again; the page shows what it has). TMDB says ADULT → the flag is// stored, `detailsCheck = 'adult'`, no poster/seasons (mission 056: an adult title is never public, its poster not kept).//// WHO CALLS IT (`completeStep`, again and again until `done`): the show page when an incomplete title is opened// (components/show.hl face `showComplete`, skeleton meanwhile) and the repair job in project.hl (`repairTick`, one step per tick// on the app's clock, like the adult backfill) for every incomplete title that exists when the app starts. Adult titles are// left alone by both.//// CAST AND CREW (tracker.worldapi.org#28; creator: "you show quite few people, usually there are more especially at picard, and// directors and so"). From the SAME details answer (`credits` of a movie, `aggregate_credits` of a series = every season):// cast [{ person, character, actor, episodes, guest }] — a movie: TMDB's order (billing); a series: TMDB's aggregate order// (by episode count, most first; `character` = all roles " / "), `episodes` = the person's episodes, `guest` = fewer than// `mainMinOf` (half the series' episodes, at most 10, at least 1) → the page puts them after "Guest stars". At most// `castMax` (300: a talk show/soap lists 3,000+ one-episode guests — Grey's Anatomy 3,243, SNL 2,771 — each would be a// new person); adult people left out; entries we had and TMDB does not list are kept after them.// GUESTS ARE NOT PERSON RECORDS (mission 062, memory: 160k people = +3.3 GB RSS at boot): a guest entry is// { person (only when we have them already, else null), tmdbId, actor, character, profile (TMDB's profile_path), episodes,// guest = true } — the page links it to `/person/tmdb/<tmdbId>?show=<show id>`, which creates the person on the first open// (people.hl guestPersonId). The main cast and the crew are linked/created at once as before.// crew [{ person, name, job, episodes }] job = creator (a series' `created_by`) | director (Director) | writer (Writer) |// screenplay (Screenplay) | story (Story) | composer (Original Music Composer, Composer, Music) — one entry per person and// job, a series' by that job's episode count (most first), a movie's in TMDB's order; at most `crewMax` per job.// `creditsAt` (ms) = the credits are stored (by any path); `creditsCheck` = why the credits job gave up (TMDB 404, adult).// Every person is linked (people.hl castPersonId: the one we have by TMDB id, else a NEW minimal person — slug, search entry,// this one credit; their page fills on the first visit). WHO STORES THEM: the details step below (incomplete titles), the daily// sync and the search import (tmdbsync.hl asks for the credits in the details request it makes anyway → `applyCredits`), and// the CREDITS JOB (project.hl `creditsTick`, `creditsStep` here) for every other public title once — one request per title.import { now } from 'hl:time'import { env } from 'hl:proc'import { showsTable, personsTable, kindOfTitle } from './shows.hl'import { tmdbGet, textOr, adultOf, syncShowWith, syncShowPart, persistSync } from './tmdbsync.hl'import { searchTitleChanged } from './search.hl'import { refreshCatalogShow } from './catalog.hl'import { castPersonId, castPeopleCount, genreIdsOfTmdb, personIdByTmdb } from './people.hl'// how many of TMDB's cast a title keeps (see the top; TRACKER_CAST_MAX) and how many people per crew jobstatic castMax = env('TRACKER_CAST_MAX') != null ? toNumber(env('TRACKER_CAST_MAX')) : 300static crewMax = 30static listOr = (&v) => { return v == null ? [] : v }// INCOMPLETE = a filmography title (`minimal`) or an import whose sync never finished (`imported`, no `tmdbSync`), not// completed (`detailsAt`) and not given up on (`detailsCheck`)static isIncomplete = (&s) => {return s != null && s.tmdbId != null && s.detailsAt == null && s.detailsCheck == null && (s.minimal == true || (s.imported != null && s.tmdbSync == null))}// what the show page and the repair complete: an incomplete title that is not adult (mission 054/056: never public)static needsDetails = (&s) => { return isIncomplete(s) && s.adult != true }// every title the repair job has to do, in table order (read once when it starts — so a restart goes on with what is left)static detailsRepairQueue = () => {let out = []for (s of showsTable.find(null, null)) { if (needsDetails(s)) { out.push(s.id) } }return out}// a series' roles ([{ character, episode_count }]) as one text: "Mother / Lamia". tracker#28 (real data): TMDB's characters are// themselves " / "-joined and repeat across seasons ("Data / Lore", "Data", "Yvette Picard (uncredited)") — split, " (uncredited)"// dropped, each name once, at most `rolesMax` (a voice actor of The Simpsons has 30+), then " / …"static rolesMax = 4static rolesText = (&roles) => {let names = []let more = falsefor (r of listOr(roles)) {let c = r != null ? textOr(r.character) : nullif (c != null) {for (part of c.split(' / ')) {let n = part.replaceAll(' (uncredited)', '').trim()if (n != '' && !names.includes(n)) { if (names.length < rolesMax) { names.push(n) } else { more = true } }}}}return names.join(' / ') + (more ? ' / …' : '')}// a series' episodes of one person: TMDB's total_episode_count, else the sum of the roles' / jobs' countsstatic episodesOf = (&total, &parts) => {if (total != null && hlTypeName(total) == 'Number') { return total }let n = 0for (r of listOr(parts)) { if (r != null && r.episode_count != null && hlTypeName(r.episode_count) == 'Number') { n = n + r.episode_count } }return n}static personOk = (&c) => { return c != null && c.id != null && hlTypeName(c.id) == 'Number' && textOr(c.name) != null && c.adult != true }// TMDB's cast → [{ tmdbId, name, character, gender, episodes }], TMDB's order, at most castMax, adult people left outstatic castOf = (&d, &movie) => {let src = movie ? (d.credits != null ? d.credits.cast : null) : (d.aggregate_credits != null ? d.aggregate_credits.cast : null)let out = []for (c of listOr(src)) {if (out.length < castMax && personOk(c)) {let role = movie ? textOr(c.character) : rolesText(c.roles)out.push({ tmdbId = c.id name = c.name character = role != null ? role : '' gender = c.gender profile = textOr(c.profile_path) episodes = movie ? null : episodesOf(c.total_episode_count, c.roles) })}}return out}// a series' main cast is in at least this many episodes: half of the series' (rounded up), at most 10 (a season), at least 1static mainMinOf = (&d) => {let n = d.number_of_episodes != null && hlTypeName(d.number_of_episodes) == 'Number' ? d.number_of_episodes : 0let half = (n - n % 2) / 2 + n % 2if (half > 10) { return 10 }return half < 1 ? 1 : half}// the crew jobs we show: TMDB job → ours (the page's labels: components/show.hl crewLabels)static crewJobOf = (&job) => {if (job == 'Director') { return 'director' }if (job == 'Writer') { return 'writer' }if (job == 'Screenplay') { return 'screenplay' }if (job == 'Story') { return 'story' }if (job == 'Original Music Composer' || job == 'Composer' || job == 'Music') { return 'composer' }return null}static crewJobs = ['creator', 'director', 'writer', 'screenplay', 'story', 'composer']// keeps a job's list ordered by episodes (most first, equal ones in TMDB's order), at most crewMax (hand-written: no sort())static insertByEpisodes = (&list, &item) => {let out = []let inserted = falsefor (x of list) {if (!inserted && item.episodes > x.episodes) { out.push(item) inserted = true }if (out.length < crewMax) { out.push(x) }}if (!inserted && out.length < crewMax) { out.push(item) }return out}// TMDB's crew (+ a series' created_by) → [{ tmdbId, name, job, gender, episodes }], grouped by job in `crewJobs` order, one// entry per person and jobstatic crewOf = (&d, &movie) => {let byJob = {}let seen = {}for (j of crewJobs) { byJob[j] = [] }if (!movie) {for (c of listOr(d.created_by)) {if (personOk(c) && seen['creator:' + c.id] != true && byJob['creator'].length < crewMax) {seen['creator:' + c.id] = truelet l = byJob['creator']l.push({ tmdbId = c.id name = c.name job = 'creator' gender = c.gender episodes = 0 })byJob['creator'] = l}}}let src = movie ? (d.credits != null ? d.credits.crew : null) : (d.aggregate_credits != null ? d.aggregate_credits.crew : null)for (c of listOr(src)) {if (personOk(c)) {// a movie's crew entry has one `job`; a series' has `jobs` [{ job, episode_count }]let jobs = movie ? [{ job = c.job episode_count = 0 }] : listOr(c.jobs)for (jb of jobs) {let ours = jb != null ? crewJobOf(jb.job) : nullif (ours != null && seen[ours + ':' + c.id] != true) {seen[ours + ':' + c.id] = truelet n = movie ? 0 : episodesOf(jb.episode_count, null)byJob[ours] = insertByEpisodes(byJob[ours], { tmdbId = c.id name = c.name job = ours gender = c.gender episodes = n })}}}}let out = []for (j of crewJobs) { for (c of byJob[j]) { out.push(c) } }return out}// the title's cast: TMDB's (linked to our people, new ones added) first, then the entries we had that TMDB does not list.// `mainMin` = a series' main-cast episode count (mainMinOf), 0 for a movie (no guests)static mergedCast = (&show, &tmdbCast, &mainMin) => {let out = []let have = {}let showKey = show.idlet seenTmdb = {}for (c of tmdbCast) {let guest = c.episodes != null && c.episodes < mainMin// a guest: linked only when we have them, never created here (mission 062)let pid = guest ? personIdByTmdb(c.tmdbId) : castPersonId(c, showKey)if (pid != null && have[pid] != true) {have[pid] = trueseenTmdb['' + c.tmdbId] = trueout.push({ person = pid tmdbId = c.tmdbId character = c.character actor = c.name profile = c.profile episodes = c.episodes guest = guest })} else if (pid == null && guest && seenTmdb['' + c.tmdbId] != true) {seenTmdb['' + c.tmdbId] = trueout.push({ person = null tmdbId = c.tmdbId character = c.character actor = c.name profile = c.profile episodes = c.episodes guest = true })}}for (c of listOr(show.cast)) {if (c != null && c.person != null && have[c.person] != true && (c.tmdbId == null || seenTmdb['' + c.tmdbId] != true)) {have[c.person] = trueout.push(c)}}return out}static crewRoleOf = (&job) => {if (job == 'creator') { return 'Creator' }if (job == 'director') { return 'Director' }if (job == 'writer') { return 'Writer' }if (job == 'screenplay') { return 'Screenplay' }if (job == 'story') { return 'Story' }return 'Music'}// the title's crew: TMDB's, linked to our people (a crew person new to us gets the job as their role) — replaces what we hadstatic linkedCrew = (&show, &tmdbCrew) => {let out = []let showKey = show.idfor (c of tmdbCrew) {let pid = castPersonId({ tmdbId = c.tmdbId name = c.name character = crewRoleOf(c.job) gender = c.gender }, showKey)if (pid != null) { out.push({ person = pid name = c.name job = c.job episodes = c.episodes }) }}return out}// the answer carries credits (a details request with `credits` / `aggregate_credits` appended)static hasCredits = (&d, &movie) => { return d != null && (movie ? d.credits != null : d.aggregate_credits != null) }// cast + crew of this record from a details answer → { show (the record with cast, crew, creditsAt), cast, crew, people (new) }static withCredits = (show, &d, &movie) => {let before = castPeopleCount()let tmdbCast = castOf(d, movie)let x = showx.cast = mergedCast(show, tmdbCast, movie ? 0 : mainMinOf(d))x.crew = linkedCrew(show, crewOf(d, movie))x.creditsAt = now()return { show = x cast = tmdbCast.length crew = x.crew.length people = castPeopleCount() - before }}// THE RECORD'S OWN FIELDS from a details answer (tracker#26 completion; tracker#18 the daily sync too — creator: "some shows changed// descriptions and titles and that did not update"): title (a series' `name`, a movie's `title`), TMDB's overview → `tmdbSummary`,// homepage, tagline, status, genres (by TMDB id), adult; type / release / year / language only when we have none. A TMDB value// that is empty never overwrites ours. `summary` is the creator's OWN text — never written here (the page shows summary >// tmdbSummary > tvmazeSummary, shows.hl summaryOf). The slug (`urlSegment`) never changes with the title.// mission 070 (conductor): the sync never DESTROYS what the old tracker's migration brought — before TMDB's value replaces a// migrated record's title / genres / homepage / tagline for the FIRST time, the old one is kept in `migratedTitle` /// `migratedGenres` / `migratedHomepage` / `migratedTagline` (kept, shown nowhere; set once, a later rename leaves it). A record is// migrated when its `oldId` is the old tracker's (not `tmdb-…`: search import, filmography fill). `status`, counts, ids and the// poster are TMDB's state, written by the sync since #9 — not kept.static isMigratedRecord = (&show) => { return show.oldId != null && ('' + show.oldId).slice(0, 5) != 'tmdb-' }static sameIdList = (&a, &b) => {let x = listOr(a)let y = listOr(b)if (x.length != y.length) { return false }let i = 0while (i < x.length) { if ('' + x[i] != '' + y[i]) { return false } i = i + 1 }return true}static withDetailsFields = (show, &d, &movie) => {let x = showlet migrated = isMigratedRecord(show)let adult = adultOf(d)if (adult != null) { x.adult = adult }let title = textOr(movie ? d.title : d.name)if (title != null) {if (migrated && x.migratedTitle == null && textOr(x.title) != null && x.title != title) { x.migratedTitle = x.title }x.title = title}if (textOr(d.overview) != null) { x.tmdbSummary = d.overview }if (textOr(d.homepage) != null) {if (migrated && x.migratedHomepage == null && textOr(x.homepage) != null && x.homepage != d.homepage) { x.migratedHomepage = x.homepage }x.homepage = d.homepage}if (textOr(d.tagline) != null) {if (migrated && x.migratedTagline == null && textOr(x.tagline) != null && x.tagline != d.tagline) { x.migratedTagline = x.tagline }x.tagline = d.tagline}if (textOr(d.status) != null) { x.status = d.status }let tmdbGenreIds = []for (g of listOr(d.genres)) { if (g != null && g.id != null) { tmdbGenreIds.push(g.id) } }let genres = genreIdsOfTmdb(tmdbGenreIds)if (genres.length > 0) {if (migrated && x.migratedGenres == null && listOr(x.genres).length > 0 && !sameIdList(x.genres, genres)) { x.migratedGenres = x.genres }x.genres = genres}if (textOr(x.type) == null) { x.type = movie ? 'movie' : 'series' }let release = textOr(movie ? d.release_date : d.first_air_date)if (textOr(x.release) == null && release != null) {x.release = releaseif (release.length >= 4) { x.year = toNumber(release.slice(0, 4)) }}if (textOr(x.language) == null && textOr(d.original_language) != null) { x.language = d.original_language }// tracker#21 (merge, mission 070): TMDB's TV type + the kind from the genres just stored (the delta's light step and the// completion of #26 store it too, not only syncShow / the kind backfill)if (!movie && textOr(d.type) != null) {x.tmdbType = d.typex.kind = kindOfTitle(d.type, x.genres)}return x}// THE DAILY SYNC (project.hl syncTick, tools/sync-tmdb.hl; tracker#18): after tmdbsync.hl `syncShow` (seasons, episodes, poster,// ids, TVmaze) the record's own fields (withDetailsFields) and its cast + crew (#28) from the SAME details answer (`res.details`)// — no extra request. Answers { renamed (the title changed: the search re-indexes it), cast, crew, people, ms, error }static applySynced = (&showId, &d) => {let out = { renamed = false cast = 0 crew = 0 people = 0 ms = 0 error = '' }let t0 = now()let show = showsTable.fetch(showId)if (show == null || d == null) { return out }let movie = show.type == 'movie'let before = show.titlelet x = withDetailsFields(show, d, movie)out.renamed = x.title != beforeif (x.adult != true && hasCredits(d, movie)) {let w = withCredits(x, d, movie)x = w.showout.cast = w.castout.crew = w.crewout.people = w.people}if (showsTable.update(x.id, x) == null) { out.error = x.title + ': record not stored: ' + showsTable.lastError() }out.ms = now() - t0return out}// the search import: its details answer (tmdbsync.hl `res.details`) → the title's cast + crew. Nothing// for an adult title or an answer without credits. Answers { cast, crew, people, ms, error }static applyCredits = (&showId, &d) => {let out = { cast = 0 crew = 0 people = 0 ms = 0 error = '' }let t0 = now()let show = showsTable.fetch(showId)if (show == null || show.adult == true || adultOf(d) == true || !hasCredits(d, show.type == 'movie')) { return out }let w = withCredits(show, d, show.type == 'movie')if (showsTable.update(show.id, w.show) == null) { out.error = show.title + ': credits not stored: ' + showsTable.lastError() return out }out.cast = w.castout.crew = w.crewout.people = w.peopleout.ms = now() - t0return out}// A SERIES IS DONE IN STEPS (real data: The Late Show, 22 seasons / 4,252 episodes, took 10.7 s as one step — hl:web serves one// request at a time, every page waited): step 1 = the details request (fields + cast); then one step per PART of its seasons// (≤ `partEpisodes` episodes, ≤ 20 seasons = one TMDB request — tmdbsync.hl syncShowPart); the last step = ids, TVmaze, poster,// `detailsAt`. A movie is one step. Between two steps every other request is served.// show id → { d (TMDB's answer of step 1), parts ([[season numbers]]), pos, sum } while a series is under way — in memory: a// restart begins again with the details request; what is stored by then is matched by tmdbId, nothing is duplicatedstatic jobs = {}// (TRACKER_DETAILS_PART_EPISODES: the gate sets 1 → one season per step)static partEpisodes = env('TRACKER_DETAILS_PART_EPISODES') != null ? toNumber(env('TRACKER_DETAILS_PART_EPISODES')) : 300static emptySum = () => { return { steps = 0 requests = 0 tvmazeRequests = 0 cast = 0 crew = 0 people = 0 newSeasons = 0 newEpisodes = 0 posters = 0 ids = 0 ms = 0 error = '' } }static addSum = (sum, &r, &ms) => {let o = sumo.steps = o.steps + 1o.requests = o.requests + r.requestso.tvmazeRequests = o.tvmazeRequests + r.tvmazeRequestso.newSeasons = o.newSeasons + r.newSeasons + r.tvmazeSeasonso.newEpisodes = o.newEpisodes + r.newEpisodes + r.tvmazeEpisodeso.posters = o.posters + r.posterso.ids = o.ids + r.idso.ms = o.ms + msif (r.error != '') { o.error = r.error }return o}// TMDB's season list → parts of ≤ partEpisodes episodes (a bigger season is a part of its own), ≤ 20 seasons each; which of// them are really synced (season 0, empty ones) syncShowPart decidesstatic seasonParts = (&d) => {let parts = []let cur = []let n = 0for (x of listOr(d.seasons)) {if (x != null && x.season_number != null) {let c = x.episode_count != null ? x.episode_count : 0if (cur.length > 0 && (n + c > partEpisodes || cur.length == 20)) {parts.push(cur)cur = []n = 0}cur.push(x.season_number)n = n + c}}if (cur.length > 0) { parts.push(cur) }return parts}// a step's answer: { skipped (nothing to do), done, requests / tvmazeRequests (THIS step's — what the pause counts), adult,// error ('' = none), retry (a passing failure: no answer, 429, 5xx), sum (the whole title's counts so far: steps, requests,// cast, newSeasons, newEpisodes, posters, ids, ms) }static stepOut = () => { return { skipped = false done = false requests = 0 tvmazeRequests = 0 adult = null error = '' retry = false sum = emptySum() } }// the last step: the title is completestatic markComplete = (&showId, &sum) => {let done = showsTable.fetch(showId)done.detailsAt = now()if (sum.error != '') { done.detailsError = sum.error }let ok = showsTable.update(done.id, done) != nullrefreshCatalogShow(done.id)searchTitleChanged(done.id)return ok}// step 1: the details request — the record's fields, the cast; a movie also its ids + poster (done); a series' parts are plannedstatic firstStep = (show) => {let out = stepOut()let t0 = now()let movie = show.type == 'movie'out.requests = 1let d = tmdbGet((movie ? '/movie/' : '/tv/') + show.tmdbId + '?append_to_response=external_ids,' + (movie ? 'credits' : 'aggregate_credits'))if (d.failed != null) {out.error = show.title + ': ' + d.failedif (d.status == 404) {show.detailsCheck = 'TMDB 404'showsTable.update(show.id, show)out.done = true} else { out.retry = true }return out}out.adult = adultOf(d)let x = withDetailsFields(show, d, movie)if (out.adult == true) {// never public: the flag, no poster, no seasons, no cast (mission 056)x.detailsCheck = 'adult'showsTable.update(x.id, x)refreshCatalogShow(x.id)searchTitleChanged(x.id)out.done = truereturn out}// tracker#28: the whole cast + the crew, from this same answerlet w = withCredits(x, d, movie)x = w.showif (showsTable.update(x.id, x) == null) {out.error = show.title + ': not updated: ' + showsTable.lastError()out.retry = truereturn out}let sum = emptySum()sum.cast = w.castsum.crew = w.crewsum.people = w.peopleif (movie) {// one request was enough: ids + poster now (syncMovie with this answer)let r = syncShowWith(x.id, d)out.requests = r.requestsout.sum = addSum(sum, r, now() - t0)if (r.error != '') { out.error = r.error }markComplete(x.id, out.sum)out.done = truereturn out}sum.steps = 1sum.requests = 1sum.ms = now() - t0// the next steps need the details, not the credits (a long series' aggregate_credits are ~1 MB, copied on every step)let dd = ddd.aggregate_credits = nulljobs[x.id] = { d = dd parts = seasonParts(d) pos = 0 sum = sum }out.sum = sumreturn out}// ONE step of completing this title (by our id) — called until `done` (the page's face, the repair job)static completeStep = (&showId) => {let show = showsTable.fetch(showId)if (!needsDetails(show)) {jobs[showId] = nulllet out = stepOut()out.skipped = trueout.done = truereturn out}let job = jobs[showId]if (job == null) { return firstStep(show) }let t0 = now()let out = stepOut()let last = job.pos >= job.parts.lengthlet r = syncShowPart(showId, job.d, { seasons = last ? [] : job.parts[job.pos] finish = last })out.requests = r.requestsout.tvmazeRequests = r.tvmazeRequestsif (r.error != '') { out.error = r.error }let j = jobj.sum = addSum(j.sum, r, now() - t0)j.pos = j.pos + 1out.sum = j.sumif (!last) {jobs[showId] = jreturn out}jobs[showId] = nullmarkComplete(showId, j.sum)out.done = truereturn out}// the line every completed title logsstatic completeLine = (&title, &sum) => {return '"' + title + '" steps=' + sum.steps + ' requests=' + sum.requests + ' tvmazeRequests=' + sum.tvmazeRequests + ' cast=' + sum.cast + ' newSeasons=' + sum.newSeasons+ ' newEpisodes=' + sum.newEpisodes + ' posters=' + sum.posters + ' ids=' + sum.ids + ' ms=' + sum.ms + ' crew=' + sum.crew + ' people=' + sum.people + (sum.error != '' ? ' error: ' + sum.error : '')}// flush shows/seasons/episodes + people (new cast members)static persistDetails = () => {persistSync()personsTable.persist()return true}// the credits job touches only shows + people: the episodes' index files (~16 MB on the live data) are not rewrittenstatic persistCredits = () => {showsTable.persist()personsTable.persist()return true}// ---- THE CREDITS JOB (tracker#28, project.hl `creditsTick`): every public title that has no credits yet, once --------------// (the incomplete ones get theirs from completeStep; adult and unknown-flag titles are never asked — not public)static needsCredits = (&s) => { return s != null && s.tmdbId != null && s.adult == false && s.creditsAt == null && s.creditsCheck == null && !isIncomplete(s) }// in table order, read once when the job starts (a restart goes on with what is left)static creditsQueue = () => {let out = []for (s of showsTable.find(null, null)) { if (needsCredits(s)) { out.push(s.id) } }return out}// ONE title: one request (details + credits / aggregate_credits) → its cast + crew. Answers { skipped, requests, cast, crew,// people, ms, error, retry (no answer, 429, 5xx: ask again later) }. TMDB 404 / adult → `creditsCheck`, never asked again.static creditsStep = (&showId) => {let out = { skipped = false requests = 0 cast = 0 crew = 0 people = 0 ms = 0 error = '' retry = false }let t0 = now()let show = showsTable.fetch(showId)if (!needsCredits(show)) { out.skipped = true return out }let movie = show.type == 'movie'out.requests = 1let d = tmdbGet((movie ? '/movie/' : '/tv/') + show.tmdbId + '?append_to_response=' + (movie ? 'credits' : 'aggregate_credits'))if (d.failed != null) {out.error = show.title + ': ' + d.failedif (d.status == 404) {show.creditsCheck = 'TMDB 404'showsTable.update(show.id, show)} else { out.retry = true }return out}if (adultOf(d) == true) {show.creditsCheck = 'adult'showsTable.update(show.id, show)return out}let w = withCredits(show, d, movie)if (showsTable.update(show.id, w.show) == null) {out.error = show.title + ': credits not stored: ' + showsTable.lastError()out.retry = truereturn out}out.cast = w.castout.crew = w.crewout.people = w.peopleout.ms = now() - t0return out}
Branches
- mainmain branch
Latest commits
- 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
- 1704ec45tracker#17 (mission 057): season caret down/up, skeleton rows while a season loads, sessionless showSeasonEpisodes face (no page re-mount), client-only close; gate 214, tests/realdata-057.mjs, README + STATUSmre
- f2fe3e36mission 056: README + STATUS (merge, fixes, Hybriel 8590df63, real-data check), tests/realdata-056.mjs, tools/check-public-slugs.hlmre
- f40c250emission 056: re-vendor hybriel master 8590df63 (#121, #122); an adult title's page is Not found for non-followers; gate: leave the page before stopping the servermre