tracker
All repositories: gitoria
41.0 KB
// tmdbsync.hl — THE TMDB SYNC (tracker.worldapi.org#9): for every show SOMEONE follows, fetch the show's// details, seasons and episodes from TMDB and bring storage/mpackdb/ up to date, plus the show's poster.// Used by BOTH runners: the daily run inside the app (project.hl `syncTick`, one show per step) and the// command-line tool (tools/sync-tmdb.hl). Reference for the API use: the old tracker's// crates/sync/commands/sync.js (read-only).//// GET <TMDB_BASE_URL>/tv/<tmdbId>?append_to_response=external_ids,aggregate_credits the details (status,// counts, overview, poster_path, seasons[])// + external_ids (imdb_id, tvdb_id) + the cast/crew// of every season (tracker#28: the answer goes back// as `res.details`, the caller stores the credits —// details.hl applyCredits; no extra request)// GET <TMDB_BASE_URL>/tv/<tmdbId>?append_to_response=season/1,season/2… up to 20 seasons with their// episodes in ONE request// GET <TMDB_IMAGE_URL>/w342<poster_path> the poster → storage/mpackdb/posters/// Authorization: Bearer $TMDB_READ_TOKEN// GET <TVMAZE_BASE_URL>/lookup/shows?imdb=tt… (else ?thetvdb=<id>) the TVmaze id, ONLY when the show// has none (301 → /shows/<id>, 404 = unknown)// GET <TVMAZE_BASE_URL>/shows/<tvmzId>?embed=episodes a SERIES on TVmaze with its episodes (#15), see// below; its summary → `tvmazeSummary` (tracker#18)// A MOVIE (`type = 'movie'`, 7814 of the migrated rows, 1 followed): only// GET <TMDB_BASE_URL>/movie/<tmdbId>?append_to_response=external_ids,credits its imdb id + poster (+ credits); no seasons,// status, overview (it is no TV show)//// EXTERNAL IDS (tracker.worldapi.org#12, the show page's link icons): a MISSING imdbId/tvdbId is filled from TMDB's// external_ids, a missing tvmzId from TVmaze's lookup — an id the show already has is NEVER overwritten.// RULES (ticket): new seasons/episodes are ADDED, existing ones UPDATED (title, summary, release) —// matched by tmdbId, never duplicated; NOTHING is deleted. An episode without a tmdbId (161 migrated ones)// is matched by its S/E number instead and gets the tmdbId. A season (no tmdbId in the migrated data) is// matched by (show, seasonNumber). A TMDB value that is empty/null never overwrites a stored one.// Season 0 ("Specials") is only synced for a show that already has a season 0 (else every followed show// would suddenly flood /my/unwatched with specials); a TMDB season with no episodes is not created.//// TVMAZE MERGE (tracker.worldapi.org#15): AFTER TMDB, a series with a tvmzId gets TVmaze's episode list (one request)// and TVmaze FILLS GAPS only — matched by season/episode number: a missing episode (and its season) is ADDED, an empty// title or air date of an existing one is FILLED; a value TMDB gave is never overwritten. A placeholder title// ("Episode 4", "TBA") counts as empty — and TMDB's own placeholder never overwrites a real title either (else the// two would flip it every day). When TMDB later lists the episode, the TMDB step adopts it by number (it has no tmdbId)// and TMDB's values win. TVmaze specials (no episode number) are skipped, season 0 only if the show has one. SAFETY:// when the two number a show differently (fewer than 80 % of the episodes both know air within a day of each other or// share the title — `numberingAgrees`), TVmaze is ignored for that show (`tvmazeSkipped`). Summaries are not taken// (TVmaze's are HTML; the ticket asks for episodes, air dates, titles). The SHOW's TVmaze summary is kept, HTML stripped, as// `tvmazeSummary` (tracker#18; the page shows summary > tmdbSummary > tvmazeSummary, shows.hl summaryOf).//// WHICH TITLES, WHEN (tracker#18): the daily run asks TMDB's and TVmaze's change lists first and syncs only the titles of ours// that changed — deltasync.hl. THE SHOW RECORD (title, tagline, homepage, status, genres …) is refreshed from the same details// answer by details.hl `applySynced`.//// ADULT FLAG (mission 054): every details answer carries TMDB's `adult` — the sync, the import (search.hl) and the// BACKFILL below store it as show.adult (true/false; null = not known yet, hidden from the public lists, shows.hl// isPublicTitle). THE BACKFILL (`backfillTitle`, run one title per step by project.hl `backfillTick`, like the daily sync):// GET <TMDB_BASE_URL>/movie|tv/<tmdbId>?append_to_response=external_ids ONE request: adult, poster_path, imdb/tvdb id// GET <TMDB_IMAGE_URL>/w342<poster_path> the poster when its file is missing (not for// an adult title — never shown, mission 056)// for every title whose adult flag is unknown (no TVmaze, no seasons — the daily sync does those for followed shows). A// title TMDB answers 404 for (or one without tmdbId) gets `adultCheck` = why, stays unknown (hidden) and is not asked again.//// IDS ARE SELF-ASSIGNED (randomBytes, like watches.hl): hl:mpackdb's own generated @id can repeat within// one millisecond, which is what made update() lose rows (hybriel#113, fixed upstream, not in the vendored// build) — a table holding only unique ids is safe to update().//// ONE PROCESS PER STORAGE: hl:mpackdb does not coordinate two processes (hybriel#21) — the in-app run uses// the app's own open tables (imported from shows.hl); the tool only ever runs against a stopped app or a copy.//// Env: TMDB_READ_TOKEN (required unless TMDB_BASE_URL points at a fake), TMDB_BASE_URL// (https://api.themoviedb.org/3), TMDB_IMAGE_URL (https://image.tmdb.org/t/p) — the gate points both at its// fake TMDB (tests/browser.mjs).import { fetch } from 'hl:fetch'import { env } from 'hl:proc'import { now } from 'hl:time'import { randomBytes } from 'hl:crypto'import { writeFile, mkDir, exists } from 'hl:fs'import { showsTable, seasonsTable, episodesTable, storageDir } from './shows.hl'static envOr = (name, fallback) => {let v = env(name)return v != null && v.trim() != '' ? v.trim() : fallback}static tmdbBase = envOr('TMDB_BASE_URL', 'https://api.themoviedb.org/3')static tmdbImageBase = envOr('TMDB_IMAGE_URL', 'https://image.tmdb.org/t/p')static tmdbToken = envOr('TMDB_READ_TOKEN', '')static postersDir = storageDir + '/posters'static tvmazeBase = envOr('TVMAZE_BASE_URL', 'https://api.tvmaze.com')// the sync can run: a token is set (never printed) — the dev .env on Loreana has one, a gate sets its ownstatic syncEnabled = tmdbToken != ''// TMDB allows 40 requests / 10 s: every runner waits this long per request it made before the next showstatic msPerRequest = 260// TVmaze allows 20 requests / 10 s (its API docs): 550 ms per lookup madestatic msPerTvmazeRequest = 550// how long a runner waits after a show before the next one (both runners: project.hl syncTick, tools/sync-tmdb.hl)static pauseMsAfter = (r) => { return r.requests * msPerRequest + r.tvmazeRequests * msPerTvmazeRequest }// one GET → the parsed JSON, or { failed = '<why>' }. A refused/timed-out fetch is an Error event (absorbed// by the runner's `on Error`), the fetch then yields null.static tmdbGet = (path) => {let r = fetch(tmdbBase + path, { headers = { 'authorization' = 'Bearer ' + tmdbToken 'accept' = 'application/json' 'user-agent' = 'tracker.worldapi.org (tmdb sync)' } timeoutMs = 20000 })if (r == null || r.status == null || r.status == 0) { return { failed = 'no answer for ' + path } }if (r.status != 200) { return { failed = 'HTTP ' + r.status + ' for ' + path status = r.status } }let j = r.json()if (j == null) { return { failed = 'no JSON for ' + path } }return j}static textOr = (v) => { return v != null && hlTypeName(v) == 'String' && v.trim() != '' ? v : null }// ---- external ids (tracker.worldapi.org#12) -----------------------------------------------------static hasId = (v) => { return v != null && ('' + v).trim() != '' }// an IMDb id as TMDB gives it ('tt' + digits), else nullstatic imdbOr = (v) => {let t = textOr(v)if (t == null || t.length < 3 || t.slice(0, 2) != 'tt') { return null }let i = 2while (i < t.length) {if (!'0123456789'.includes(t[i])) { return null }i = i + 1}return t}// a positive numeric id (TMDB's tvdb_id, TVmaze's id), else nullstatic numIdOr = (v) => { return v != null && hlTypeName(v) == 'Number' && v > 0 ? v : null }// TVmaze's lookup by one external id: { id } (null = TVmaze doesn't know it, 404) or { failed }. hl:fetch follows the// 301 to /shows/<id> by itself, so a hit answers the show's own JSON.static tvmazeLookup = (param, value) => {let r = fetch(tvmazeBase + '/lookup/shows?' + param + '=' + value, { headers = { 'accept' = 'application/json' 'user-agent' = 'tracker.worldapi.org (tmdb sync)' } timeoutMs = 20000 })if (r == null || r.status == null || r.status == 0) { return { failed = 'TVmaze: no answer for ' + param + '=' + value } }if (r.status == 404) { return { id = null } }if (r.status != 200) { return { failed = 'TVmaze: HTTP ' + r.status + ' for ' + param + '=' + value } }let j = r.json()if (j == null || numIdOr(j.id) == null) { return { failed = 'TVmaze: no id for ' + param + '=' + value } }return { id = j.id }}// the ids to SET on a show from TMDB's external_ids (+ TVmaze for a series): { imdbId, tvdbId, tvmzId } — each the// show's own value when it has one (never overwritten), else the found one, else null — plus { filled, tvmazeRequests,// failed }. TVmaze is asked by imdb, then by thetvdb, only while the show (after TMDB) still has no tvmzId — and only// when `askTvmaze` (the adult backfill does not: one request per title)static externalIdsFor = (show, ext, isSeries, askTvmaze) => {let out = { imdbId = show.imdbId tvdbId = show.tvdbId tvmzId = show.tvmzId filled = 0 tvmazeRequests = 0 failed = null }let e = ext != null ? ext : {}if (!hasId(out.imdbId) && imdbOr(e.imdb_id) != null) { out.imdbId = e.imdb_id out.filled = out.filled + 1 }if (!isSeries) { return out }if (!hasId(out.tvdbId) && numIdOr(e.tvdb_id) != null) { out.tvdbId = e.tvdb_id out.filled = out.filled + 1 }if (hasId(out.tvmzId) || !askTvmaze) { return out }let tries = []if (imdbOr('' + out.imdbId) != null) { tries.push({ param = 'imdb' value = '' + out.imdbId }) }if (numIdOr(out.tvdbId) != null) { tries.push({ param = 'thetvdb' value = '' + out.tvdbId }) }for (t of tries) {if (!hasId(out.tvmzId) && out.failed == null) {out.tvmazeRequests = out.tvmazeRequests + 1let found = tvmazeLookup(t.param, t.value)if (found.failed != null) { out.failed = found.failed } else if (found.id != null) { out.tvmzId = found.id out.filled = out.filled + 1 }}}return out}// ---- TVmaze merge (tracker.worldapi.org#15) ------------------------------------------------------// a title that is none yet: empty, "TBA"/"TBD", or "Episode <n>" (what TMDB and TVmaze name an untitled episode; also// French/German/Spanish: real data has TVmaze "Épisode 1" where TMDB says "Episode 1")static placeholderWords = ['episode', 'épisode', 'Épisode', 'folge', 'episodio']static isPlaceholderTitle = (v) => {let t = textOr(v)if (t == null) { return true }let l = t.trim().toLowerCase()if (l == 'tba' || l == 'tbd' || l == 'tba.' || l == 'to be announced') { return true }let parts = l.split(' ')if (parts.length != 2 || !placeholderWords.includes(parts[0]) || parts[1].length == 0) { return false }let i = 0while (i < parts[1].length) {if (!'0123456789'.includes(parts[1][i])) { return false }i = i + 1}return true}// 'YYYY-MM-DD' → a day number (only differences matter), null when it is no such datestatic dayNumber = (v) => {let t = textOr(v)if (t == null || t.length < 10) { return null }let y = toNumber(t.slice(0, 4))let m = toNumber(t.slice(5, 7))let d = toNumber(t.slice(8, 10))if (y == null || m == null || d == null || m < 1 || m > 12) { return null }let before = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]let py = m <= 2 ? y - 1 : ylet leaps = (py - py % 4) / 4 - (py - py % 100) / 100 + (py - py % 400) / 400return y * 365 + leaps + before[m - 1] + d}static daysApart = (a, b) => {let x = dayNumber(a)let y = dayNumber(b)if (x == null || y == null) { return null }return x > y ? x - y : y - x}// TVmaze's text without its HTML (tracker#18: `tvmazeSummary`): tags dropped — a block tag (p, br, div, li) becomes a space —// the common entities decoded, white space collapsed; null when nothing is leftstatic blockTags = ['p', 'br', 'div', 'li', 'ul', 'ol', 'h1', 'h2', 'h3', 'h4', 'blockquote']static entities = [[' ', ' '], ['"', '"'], [''', "'"], [''', "'"], ['<', '<'], ['>', '>'], ['–', '–'], ['—', '—'], ['…', '…'], ['&', '&']]static stripHtml = (v) => {let t = textOr(v)if (t == null) { return null }let out = ''let i = 0while (i < t.length) {if (t[i] == '<') {let close = t.indexOf('>', i)if (close < 0) { close = t.length }let name = t.slice(i + 1, close).trim().toLowerCase()if (name.startsWith('/')) { name = name.slice(1) }let sp = name.indexOf(' ')if (sp >= 0) { name = name.slice(0, sp) }if (name.endsWith('/')) { name = name.slice(0, name.length - 1) }if (blockTags.includes(name)) { out = out + ' ' }i = close + 1} else {// a line break / tab (a control character) is a space too (strings have no escapes to name them)out = out + (t.charCodeAt(i) < 32 ? ' ' : t[i])i = i + 1}}for (e of entities) { out = out.replaceAll(e[0], e[1]) }let words = []for (w of out.split(' ')) { if (w != '') { words.push(w) } }return words.length > 0 ? words.join(' ') : null}// TVmaze's show WITH its episode list, one request (tracker#18: `?embed=episodes` brings the show's own summary too — before// it was `/shows/<id>/episodes`): { list, summary (HTML stripped, or null) } or { failed }static tvmazeEpisodes = (tvmzId) => {let r = fetch(tvmazeBase + '/shows/' + tvmzId + '?embed=episodes', { headers = { 'accept' = 'application/json' 'user-agent' = 'tracker.worldapi.org (tmdb sync)' } timeoutMs = 20000 })if (r == null || r.status == null || r.status == 0) { return { failed = 'TVmaze: no answer for episodes of ' + tvmzId } }if (r.status != 200) { return { failed = 'TVmaze: HTTP ' + r.status + ' for episodes of ' + tvmzId } }let j = r.json()if (j == null || j._embedded == null || j._embedded.episodes == null || j._embedded.episodes.length == null) { return { failed = 'TVmaze: no episode list for ' + tvmzId } }return { list = j._embedded.episodes summary = stripHtml(j.summary) }}// do we and TVmaze number a show the same way? Of the episodes both know (same S/E), at least 80 % must air within a day// of each other OR carry the same title (a streaming show TVmaze dates all on its release day, TMDB week by week, still// agrees by title). A show we have episodes of but TVmaze numbers none of the same (e.g. seasons by year) never agrees.// Answers { known, compared, agree, ok }.static sameTitle = (a, b) => { return !isPlaceholderTitle(a) && !isPlaceholderTitle(b) && a.trim().toLowerCase() == b.trim().toLowerCase() }static numberingAgrees = (epByNumber, list) => {let out = { known = 0 compared = 0 agree = 0 ok = true }let localCount = epByNumber.keys().lengthfor (te of list) {let e = epByNumber[te.season + 'x' + te.number]if (e != null) {out.known = out.known + 1let apart = daysApart(e.release, te.airdate)let titled = !isPlaceholderTitle(e.title) && !isPlaceholderTitle(te.name)if (apart != null || titled) {out.compared = out.compared + 1if ((apart != null && apart <= 1) || sameTitle(e.title, te.name)) { out.agree = out.agree + 1 }}}}out.ok = !((localCount > 0 && out.known == 0) || (out.compared > 0 && out.agree * 5 < out.compared * 4))return out}// Merge TVmaze's episodes of a series into what we have AFTER the TMDB step (gaps only, rules at the top).// `seasonsByNumber` / `epByNumber` = the show's seasons by number and episodes by '<s>x<e>', as the TMDB step left them.// Answers { tvmazeRequests, newSeasons, newEpisodes, filledEpisodes, skipped, errors, error, showSeasons }.static mergeTvmaze = (title, showKey, tvmzId, seasonsIn, episodesIn, showSeasonsIn) => {let seasonsByNumber = seasonsInlet epByNumber = episodesInlet out = { tvmazeRequests = 1 newSeasons = 0 newEpisodes = 0 filledEpisodes = 0 skipped = 0 errors = 0 error = '' showSeasons = showSeasonsIn summary = null }let got = tvmazeEpisodes(tvmzId)if (got.failed != null) { out.errors = 1 out.error = title + ': ' + got.failed return out }out.summary = got.summary// the numbered episodes TVmaze has in seasons we take (no specials; season 0 only when the show has one)let list = []for (te of got.list) {if (numIdOr(te.season) != null && numIdOr(te.number) != null) { list.push(te) }else if (te.season == 0 && numIdOr(te.number) != null && seasonsByNumber['0'] != null) { list.push(te) }}if (!numberingAgrees(epByNumber, list).ok) { out.skipped = 1 return out }let added = {}let addedFirstDate = {}for (te of list) {let key = te.season + 'x' + te.numberlet e = epByNumber[key]if (e == null) {let seasonKey = seasonsByNumber['' + te.season] != null ? seasonsByNumber['' + te.season].id : nullif (seasonKey == null) {if (added['' + te.season] == null) { added['' + te.season] = [] addedFirstDate['' + te.season] = textOr(te.airdate) seasonsByNumber['' + te.season] = { id = newId() isNew = true } }seasonKey = seasonsByNumber['' + te.season].id}let fresh = { id = newId() episodeNumber = te.number seasonNumber = te.seasontitle = textOr(te.name) summary = '' release = textOr(te.airdate)show = showKey season = seasonKey tmdbId = null tvmzId = te.id oldId = 'tvmaze-episode:' + te.id }if (episodesTable.put(fresh) == null) {out.errors = out.errors + 1out.error = title + ': TVmaze episode ' + te.id + ' not stored: ' + episodesTable.lastError()} else {if (added['' + te.season] == null) { added['' + te.season] = [] }let l = added['' + te.season]l.push(fresh.id)added['' + te.season] = lepByNumber[key] = freshout.newEpisodes = out.newEpisodes + 1}} else {let changed = falseif (isPlaceholderTitle(e.title) && !isPlaceholderTitle(te.name)) { e.title = te.name changed = true }if (textOr(e.release) == null && textOr(te.airdate) != null) { e.release = te.airdate changed = true }if (changed) {if (episodesTable.update(e.id, e) == null) {out.errors = out.errors + 1out.error = title + ': episode ' + e.id + ' not filled from TVmaze: ' + episodesTable.lastError()} else {epByNumber[key] = eout.filledEpisodes = out.filledEpisodes + 1}}}}// the seasons that got episodes: an existing one is updated, a new one stored and linked to the showlet showSeasons = []for (sid of showSeasonsIn) { showSeasons.push(sid) }for (n of added.keys()) {let ids = added[n]let s = seasonsByNumber[n]if (ids.length > 0 && s.isNew == true) {let season = { id = s.id seasonNumber = toNumber(n) title = 'Season ' + n release = addedFirstDate[n] summary = ''show = showKey episodes = ids oldId = 'tvmaze-season:' + tvmzId + ':' + n }if (seasonsTable.put(season) == null) {out.errors = out.errors + 1out.error = title + ': TVmaze season ' + n + ' not stored: ' + seasonsTable.lastError()} else {showSeasons.push(season.id)out.newSeasons = out.newSeasons + 1}} else if (ids.length > 0) {let season = seasonsTable.fetch(s.id)let eps = []for (eid of season.episodes) { eps.push(eid) }for (eid of ids) { eps.push(eid) }season.episodes = epsif (seasonsTable.update(season.id, season) == null) {out.errors = out.errors + 1out.error = title + ': season ' + season.id + ' not updated: ' + seasonsTable.lastError()}}}out.showSeasons = showSeasonsreturn out}// ---- posters ----------------------------------------------------------------------------------// the extension of a TMDB poster_path ('/abc.jpg' → 'jpg'); only the ones the poster route knowsstatic posterExt = (posterPath) => {let parts = posterPath.split('.')let ext = parts[parts.length - 1].toLowerCase()if (ext == 'jpg' || ext == 'jpeg' || ext == 'png' || ext == 'webp') { return ext }return null}// downloads the w342 poster when the file is missing or TMDB has a new one; answers { requests, saved, failed }// and sets show.image / show.tmdbPoster on the (caller's) record — the file is <oldId>.<ext>, the name// shows.hl posterName() already builds, served by project.hl's /posters/:name routestatic syncPoster = (show, posterPath) => {let out = { requests = 0 saved = 0 failed = null image = show.image tmdbPoster = show.tmdbPoster }if (textOr(posterPath) == null || show.oldId == null) { return out }let ext = posterExt(posterPath)if (ext == null) { return out }let file = postersDir + '/' + show.oldId + '.' + extif (exists(file) && show.tmdbPoster == posterPath && show.image == ext) { return out }out.requests = 1let r = fetch(tmdbImageBase + '/w342' + posterPath, { headers = { 'user-agent' = 'tracker.worldapi.org (tmdb sync)' } timeoutMs = 20000 })if (r == null || r.status != 200 || r.body == null || r.body.length == 0) {out.failed = 'poster ' + posterPath + ': ' + (r == null ? 'no answer' : 'HTTP ' + r.status)return out}mkDir(postersDir)writeFile(file, toBytes(r.body), 420)out.saved = 1out.image = extout.tmdbPoster = posterPathreturn out}// ---- one show -----------------------------------------------------------------------------------static newId = () => { return randomBytes(8, 'hex') }// TMDB's adult flag of a details/search answer: true / false, null when it has none (mission 054)static adultOf = (j) => {if (j == null) { return null }if (j.adult == true) { return true }if (j.adult == false) { return false }return null}// which TMDB seasons to sync: every numbered one with episodes; 0 only when the show has a season 0 alreadystatic wantedSeasonNumbers = (details, localByNumber) => {let out = []if (details.seasons == null) { return out }for (s of details.seasons) {let n = s.season_numberif (n != null && (n > 0 || localByNumber['' + n] != null) && (s.episode_count == null || s.episode_count > 0 || localByNumber['' + n] != null)) { out.push(n) }}return out}// the seasons in batches of 20 (TMDB's append_to_response limit)static batchesOf = (list, size) => {let out = []let cur = []for (x of list) {cur.push(x)if (cur.length == size) { out.push(cur) cur = [] }}if (cur.length > 0) { out.push(cur) }return out}// fields of an episode/season from TMDB that differ from the stored record (null/empty never overwrites)static changedText = (stored, fresh) => { return fresh != null && fresh != stored }// a MOVIE: one request — its imdb id (if missing) and its poster; nothing else of the record is touched. `pre` = that// request's answer when the caller already made it (details.hl firstStep, tracker#26), else nullstatic syncMovie = (show, res, pre) => {let out = resout.requests = 1let details = pre != null ? pre : tmdbGet('/movie/' + show.tmdbId + '?append_to_response=external_ids,credits')if (details.failed != null) { out.errors = 1 out.error = show.title + ': ' + details.failed return out }if (pre == null) { out.details = details }let ids = externalIdsFor(show, details.external_ids, false, false)out.ids = ids.filledlet poster = syncPoster(show, details.poster_path)out.requests = out.requests + poster.requestsout.posters = poster.savedif (poster.failed != null) { out.errors = out.errors + 1 out.error = show.title + ': ' + poster.failed }let m = showm.imdbId = ids.imdbIdm.image = poster.imagem.tmdbPoster = poster.tmdbPosterif (adultOf(details) != null) { m.adult = adultOf(details) }m.tmdbSync = now()if (showsTable.update(m.id, m) == null) { out.errors = out.errors + 1 out.error = show.title + ': movie not updated: ' + showsTable.lastError() }return out}// Sync ONE show (by our id). Answers counts: { requests, tvmazeRequests, newSeasons, newEpisodes, updatedEpisodes,// updatedSeasons, posters, ids, tvmazeEpisodes, tvmazeSeasons, tvmazeFilled, tvmazeSkipped, errors, error } + `details` (the// details answer with its credits when this call asked for it, else null — tracker#28: the caller stores the credits). Never throws for a TMDB failure — it is counted and the show left as is.static syncShow = (showId) => { return syncShowPart(showId, null, null) }// the same with the details request (`/tv|movie/<id>?append_to_response=external_ids…`) already answered — `pre`, counted as// this show's first request — so details.hl (tracker#26) asks TMDB once for details, ids AND the caststatic syncShowWith = (showId, pre) => { return syncShowPart(showId, pre, null) }// ONE PART of a series' sync (tracker#26: a talk show with 4,000 episodes took 10 s in one step, blocking every request):// `only` = { seasons (the TMDB season numbers of this part), finish (false: only those seasons — new ones linked to the show,// nothing else; true: the ids, TVmaze, poster and the show record), tvmaze (false: no TVmaze lookup/merge — tracker#18's light// step) }; null = everything at once. `pre` is required with `only` (its request is the caller's: a part counts only its own// requests).static emptyResult = () => { return { requests = 0 tvmazeRequests = 0 newSeasons = 0 newEpisodes = 0 updatedEpisodes = 0 updatedSeasons = 0 posters = 0 ids = 0 tvmazeEpisodes = 0 tvmazeSeasons = 0 tvmazeFilled = 0 tvmazeSkipped = 0 errors = 0 error = '' details = null } }static syncShowPart = (showId, pre, only) => {let res = emptyResult()let show = showsTable.fetch(showId)if (show == null) { res.errors = 1 res.error = 'no show ' + showId return res }if (show.tmdbId == null) { res.errors = 1 res.error = show.title + ': no tmdbId' return res }if (show.type == 'movie') { return syncMovie(show, res, pre) }res.requests = only != null ? 0 : 1let details = pre != null ? pre : tmdbGet('/tv/' + show.tmdbId + '?append_to_response=external_ids,aggregate_credits')if (details.failed != null) { res.errors = 1 res.error = show.title + ': ' + details.failed return res }if (pre == null) { res.details = details }// what we have: seasons by number, episodes by tmdbId and by S/E number// (tracker#18: a part reads only ITS seasons' episodes, unless it finishes with the TVmaze merge, which compares them all —// Today, 15,716 episodes, read them all for a one-season change: 20 s and GBs of memory)let tvmazeToo = only == null || only.tvmaze != falselet allEpisodes = only == null || (only.finish && tvmazeToo)let seasonsByNumber = {}let epByTmdb = {}let epByNumber = {}for (sid of show.seasons) {let s = seasonsTable.fetch(sid)if (s != null) {seasonsByNumber['' + s.seasonNumber] = slet epIds = allEpisodes || only.seasons.includes(s.seasonNumber) ? s.episodes : []for (eid of epIds) {let e = episodesTable.fetch(eid)if (e != null) {if (e.tmdbId != null) { epByTmdb['' + e.tmdbId] = e }epByNumber[e.seasonNumber + 'x' + e.episodeNumber] = e}}}}// plain locals for the record literals below: inside a literal, an entry named `show`/`season` would shadow// the variable for every later entry (SPEC "A literal's entries are evaluated in order")let showKey = show.idlet showTmdb = show.tmdbIdlet showSeasons = []for (sid of show.seasons) { showSeasons.push(sid) }let showChanged = falselet wanted = wantedSeasonNumbers(details, seasonsByNumber)if (only != null) {let part = []for (n of wanted) { if (only.seasons.includes(n)) { part.push(n) } }wanted = part}for (batch of batchesOf(wanted, 20)) {let names = []for (n of batch) { names.push('season/' + n) }res.requests = res.requests + 1let withSeasons = tmdbGet('/tv/' + show.tmdbId + '?append_to_response=' + names.join(','))if (withSeasons.failed != null) {res.errors = res.errors + 1res.error = show.title + ': ' + withSeasons.failed} else {for (n of batch) {let ts = withSeasons['season/' + n]if (ts != null && ts.episodes != null) {let season = seasonsByNumber['' + n]let seasonIsNew = season == nulllet seasonChanged = falseif (seasonIsNew) {season = { id = newId() seasonNumber = n title = textOr(ts.name) != null ? ts.name : 'Season ' + nrelease = textOr(ts.air_date) summary = textOr(ts.overview) != null ? ts.overview : ''show = showKey episodes = [] oldId = 'tmdb-season:' + showTmdb + ':' + n }} else {if (changedText(season.title, textOr(ts.name))) { season.title = ts.name seasonChanged = true }if (changedText(season.summary, textOr(ts.overview))) { season.summary = ts.overview seasonChanged = true }if (changedText(season.release, textOr(ts.air_date))) { season.release = ts.air_date seasonChanged = true }}let seasonKey = season.idlet seasonEpisodes = []for (eid of season.episodes) { seasonEpisodes.push(eid) }for (te of ts.episodes) {if (te.id != null && te.episode_number != null) {let e = epByTmdb['' + te.id]if (e == null) {let byNum = epByNumber[n + 'x' + te.episode_number]if (byNum != null && byNum.tmdbId == null) { e = byNum }}if (e == null) {let fresh = { id = newId() episodeNumber = te.episode_number seasonNumber = ntitle = textOr(te.name) summary = textOr(te.overview) != null ? te.overview : '' release = textOr(te.air_date)show = showKey season = seasonKey tmdbId = te.id oldId = 'tmdb-episode:' + te.id }let putId = episodesTable.put(fresh)if (putId == null) {res.errors = res.errors + 1res.error = show.title + ': episode ' + te.id + ' not stored: ' + episodesTable.lastError()} else {seasonEpisodes.push(fresh.id)epByTmdb['' + te.id] = freshepByNumber[n + 'x' + te.episode_number] = freshres.newEpisodes = res.newEpisodes + 1seasonChanged = true}} else {let changed = falseif (e.tmdbId == null) { e.tmdbId = te.id changed = true }// #15: TMDB's "Episode 4" never replaces a real title (one TVmaze filled, or the migrated one)if (changedText(e.title, textOr(te.name)) && !(isPlaceholderTitle(te.name) && !isPlaceholderTitle(e.title))) { e.title = te.name changed = true }if (changedText(e.summary, textOr(te.overview))) { e.summary = te.overview changed = true }if (changedText(e.release, textOr(te.air_date))) { e.release = te.air_date changed = true }if (changed) {if (episodesTable.update(e.id, e) == null) {res.errors = res.errors + 1res.error = show.title + ': episode ' + e.id + ' not updated: ' + episodesTable.lastError()} else {epByTmdb['' + e.tmdbId] = eepByNumber[e.seasonNumber + 'x' + e.episodeNumber] = eres.updatedEpisodes = res.updatedEpisodes + 1}}}}}season.episodes = seasonEpisodesif (seasonIsNew) {if (seasonsTable.put(season) == null) {res.errors = res.errors + 1res.error = show.title + ': season ' + n + ' not stored: ' + seasonsTable.lastError()} else {seasonsByNumber['' + n] = seasonshowSeasons.push(season.id)showChanged = trueres.newSeasons = res.newSeasons + 1}} else if (seasonChanged) {if (seasonsTable.update(season.id, season) == null) {res.errors = res.errors + 1res.error = show.title + ': season ' + season.id + ' not updated: ' + seasonsTable.lastError()} else {res.updatedSeasons = res.updatedSeasons + 1}}}}}}// a part that is not the last: only the new seasons are linked to the show (the next part reads them)if (only != null && !only.finish) {if (showChanged) {show.seasons = showSeasonsif (showsTable.update(show.id, show) == null) { res.errors = res.errors + 1 res.error = show.title + ': show not updated: ' + showsTable.lastError() }}return res}// the external ids (tracker.worldapi.org#12): missing ones from TMDB's external_ids, a missing TVmaze id from TVmazelet ids = externalIdsFor(show, details.external_ids, true, tvmazeToo)res.ids = ids.filledres.tvmazeRequests = ids.tvmazeRequestsif (ids.failed != null) { res.errors = res.errors + 1 res.error = show.title + ': ' + ids.failed }// tracker.worldapi.org#15: what TVmaze has and TMDB not yet (new episodes/seasons, empty titles and air dates)let tvmazeSummary = nullif (numIdOr(ids.tvmzId) != null && tvmazeToo) {let tvm = mergeTvmaze(show.title, showKey, ids.tvmzId, seasonsByNumber, epByNumber, showSeasons)res.tvmazeRequests = res.tvmazeRequests + tvm.tvmazeRequestsres.tvmazeEpisodes = tvm.newEpisodesres.tvmazeSeasons = tvm.newSeasonsres.tvmazeFilled = tvm.filledEpisodesres.tvmazeSkipped = tvm.skippedif (tvm.errors > 0) { res.errors = res.errors + tvm.errors res.error = tvm.error }showSeasons = tvm.showSeasonstvmazeSummary = tvm.summary}// the show itself: status, counts, TMDB's overview, the poster, when it was syncedlet poster = syncPoster(show, details.poster_path)res.requests = res.requests + poster.requestsres.posters = poster.savedif (poster.failed != null) { res.errors = res.errors + 1 res.error = show.title + ': ' + poster.failed }show.seasons = showSeasonsif (textOr(details.status) != null) { show.status = details.status }if (details.number_of_seasons != null) { show.seasonsCount = details.number_of_seasons }if (details.number_of_episodes != null) { show.episodesCount = details.number_of_episodes }// tracker#18: TMDB's text → tmdbSummary, TVmaze's → tvmazeSummary; `summary` is the creator's own text, never written hereif (textOr(details.overview) != null) { show.tmdbSummary = details.overview }if (tvmazeSummary != null) { show.tvmazeSummary = tvmazeSummary }show.image = poster.imageshow.tmdbPoster = poster.tmdbPostershow.imdbId = ids.imdbIdshow.tvdbId = ids.tvdbIdshow.tvmzId = ids.tvmzIdif (adultOf(details) != null) { show.adult = adultOf(details) }show.tmdbSync = now()if (showsTable.update(show.id, show) == null) { res.errors = res.errors + 1 res.error = show.title + ': show not updated: ' + showsTable.lastError() }return res}// ---- the LIGHT step of the daily delta run (tracker#18) ---------------------------------------------------------------// A changed title NOBODY follows (deltasync.hl): its details (+ ids + credits: the record, applySynced) and only the seasons// that can have changed — the ones we don't have, the ones where TMDB counts MORE episodes than we have (fewer: TVmaze's// extras, nothing to fetch), and the newest one (new or renamed episodes land there). No TVmaze (its merge only matters for the followed shows' lists; their full step still does// it). Real data (2026-10-01): Today (67 seasons / 15,716 episodes) and EastEnders (43 / 7,349) change every day; their full// sync blocked the server 20.8 s each and grew the process by ~5 GB each.static changedSeasonNumbers = (details, show) => {let ours = {}for (sid of show.seasons) {let s = seasonsTable.fetch(sid)if (s != null) { ours['' + s.seasonNumber] = s.episodes.length }}let latest = -1for (x of (details.seasons != null ? details.seasons : [])) {if (x != null && x.season_number != null && x.season_number > latest && (x.episode_count == null || x.episode_count > 0)) { latest = x.season_number }}let out = []for (x of (details.seasons != null ? details.seasons : [])) {if (x != null && x.season_number != null) {let n = x.season_numberif (n == latest || ours['' + n] == null || (x.episode_count != null && x.episode_count > ours['' + n])) { out.push(n) }}}return out}static syncShowDelta = (showId) => {let show = showsTable.fetch(showId)if (show == null || show.tmdbId == null || show.type == 'movie') { return syncShow(showId) }let details = tmdbGet('/tv/' + show.tmdbId + '?append_to_response=external_ids,aggregate_credits')if (details.failed != null) {let res = emptyResult()res.requests = 1res.errors = 1res.error = show.title + ': ' + details.failedreturn res}let r = syncShowPart(showId, details, { seasons = changedSeasonNumbers(details, show) finish = true tvmaze = false })r.requests = r.requests + 1r.details = detailsreturn r}// ---- the adult backfill (mission 054) ------------------------------------------------------------// a title still to ask: its adult flag unknown, it has a tmdbId and TMDB never answered "no such title" for itstatic needsAdultFlag = (show) => { return show.adult != true && show.adult != false && show.adultCheck == null && show.tmdbId != null }// the ids of every title the backfill still has to ask, in table order (read once when the backfill starts — so after a// restart it goes on with what is left)static adultBackfillQueue = () => {let out = []for (s of showsTable.find(null, null)) { if (needsAdultFlag(s)) { out.push(s.id) } }return out}// ONE title: one TMDB details request (adult, poster_path, external ids) + its poster when the file is missing. Answers// { requests (TMDB API requests: what the pause counts), posters, adult (true / false / null), ids, errors, error, retry// (a passing failure: no answer, 429, 5xx — ask again later) }. A 404 marks the title `adultCheck` (never asked again).static backfillTitle = (showId) => {let out = { requests = 0 posters = 0 adult = null ids = 0 errors = 0 error = '' retry = false }let show = showsTable.fetch(showId)if (show == null || !needsAdultFlag(show)) { return out }let movie = show.type == 'movie'out.requests = 1let details = tmdbGet((movie ? '/movie/' : '/tv/') + show.tmdbId + '?append_to_response=external_ids')if (details.failed != null) {out.errors = 1out.error = show.title + ': ' + details.failedif (details.status == 404) {show.adultCheck = 'TMDB 404'showsTable.update(show.id, show)} else { out.retry = true }return out}let ids = externalIdsFor(show, details.external_ids, !movie, false)out.ids = ids.filledout.adult = adultOf(details)// an ADULT title's poster is never shown publicly → not downloaded (mission 056: ~170 MB of the live data); its flag and// ids are stored as for any other titlelet poster = { saved = 0 failed = null image = show.image tmdbPoster = show.tmdbPoster }if (out.adult != true) { poster = syncPoster(show, details.poster_path) }out.posters = poster.savedif (poster.failed != null) { out.errors = out.errors + 1 out.error = show.title + ': ' + poster.failed }if (out.adult == null) { show.adultCheck = 'TMDB gave no adult flag' } else { show.adult = out.adult }show.imdbId = ids.imdbIdshow.tvdbId = ids.tvdbIdshow.image = poster.imageshow.tmdbPoster = poster.tmdbPosterif (showsTable.update(show.id, show) == null) { out.errors = out.errors + 1 out.error = show.title + ': not updated: ' + showsTable.lastError() }return out}// flush the three tables' index/meta state to disk (after a run, and every few shows)static persistSync = () => {showsTable.persist()seasonsTable.persist()episodesTable.persist()return true}// totals of a run: the per-show counts added upstatic emptyTotals = () => { return { shows = 0 requests = 0 tvmazeRequests = 0 newSeasons = 0 newEpisodes = 0 updatedEpisodes = 0 updatedSeasons = 0 posters = 0 ids = 0 tvmazeEpisodes = 0 tvmazeSeasons = 0 tvmazeFilled = 0 tvmazeSkipped = 0 errors = 0 } }static addTotals = (t, r) => {let o = to.shows = o.shows + 1o.requests = o.requests + r.requestso.tvmazeRequests = o.tvmazeRequests + r.tvmazeRequestso.ids = o.ids + r.idso.newSeasons = o.newSeasons + r.newSeasonso.newEpisodes = o.newEpisodes + r.newEpisodeso.updatedEpisodes = o.updatedEpisodes + r.updatedEpisodeso.updatedSeasons = o.updatedSeasons + r.updatedSeasonso.posters = o.posters + r.posterso.tvmazeEpisodes = o.tvmazeEpisodes + r.tvmazeEpisodeso.tvmazeSeasons = o.tvmazeSeasons + r.tvmazeSeasonso.tvmazeFilled = o.tvmazeFilled + r.tvmazeFilledo.tvmazeSkipped = o.tvmazeSkipped + r.tvmazeSkippedo.errors = o.errors + r.errorsreturn o}static totalsLine = (t) => {return 'shows=' + t.shows + ' newSeasons=' + t.newSeasons + ' newEpisodes=' + t.newEpisodes + ' updatedEpisodes=' + t.updatedEpisodes+ ' updatedSeasons=' + t.updatedSeasons + ' posters=' + t.posters + ' ids=' + t.ids + ' requests=' + t.requests + ' tvmazeRequests=' + t.tvmazeRequests + ' errors=' + t.errors+ ' tvmazeEpisodes=' + t.tvmazeEpisodes + ' tvmazeSeasons=' + t.tvmazeSeasons + ' tvmazeFilled=' + t.tvmazeFilled + ' tvmazeSkipped=' + t.tvmazeSkipped}
Branches
- mainmain branch
Latest commits
- 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
- 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
- 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
- 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
- 2b7fdd6cmission 056: signed-out header one row on phones ("Log in", nowrap), backfill skips adult titles' posters, gate checksmre
- 2c53d5efMerge branch 't16-person' (tracker#16 person pages) into main; filmography shows only public titles (054 adult flag), gate race fix (backfill start line)mre
- c171227emission 054: hide adult/unknown titles from the public lists and the search; in-app adult-flag backfill (TMDB details + poster per title, resumes), gate + real-data proofmre
- 139fafd8tracker#16: short bio (4 lines, click = all), real-data check script, README + STATUSmre
- 93be9476tracker#16: person pages /person/<slug> with the filmography fetched from TMDB on the first visit (step by step), gatemre
- 47a3cae6STATUS: mission 053 merge commit idsmre
- dcc5eecaMerge branch 't14-search'mre
- 03edc783Merge branch 't15-tvmaze'mre
- 71b46345tracker#15: numbering check by date or title, placeholder titles in other languages, docs + real-data proofmre
- 6bb2daf1tracker#13: homepage (tiles, intro, latest movies/shows), /shows, /movies/page/N, /my/movies; lists cached in memorymre
- b8bd1157tracker#14: README + STATUS (search, real-data numbers, gate, merge notes)mre
- 65c694a8tracker#14: search — header magnifier, /search/<text> (in-memory word-prefix index over titles + people), Fetch from web (TMDB search/multi, ours left out), Add = import via syncShow; gate +25 checks, real-data scriptmre
- 34f2c15btracker#15: TVmaze merge in the sync (gaps only: new episodes/seasons, empty titles/air dates; numbering check), fake TVmaze episodes + gatemre
- cbdc4ea7tracker#12: link icons TMDB/IMDb/TVDB/TVmaze; sync fills missing ids (TVmaze lookup); movies fetched via /movie/mre