gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit2c53d5ef2c53d5efMerge branch 't16-person' (tracker#16 person pages) into main; filmography shows only public titles (054 adult flag), gate race fix (backfill start line)mre2c53d5ef/tmdbsync.hl

33.3 KB

  1. // tmdbsync.hl — THE TMDB SYNC (tracker.worldapi.org#9): for every show SOMEONE follows, fetch the show's
  2. // details, seasons and episodes from TMDB and bring storage/mpackdb/ up to date, plus the show's poster.
  3. // Used by BOTH runners: the daily run inside the app (project.hl `syncTick`, one show per step) and the
  4. // command-line tool (tools/sync-tmdb.hl). Reference for the API use: the old tracker's
  5. // crates/sync/commands/sync.js (read-only).
  6. //
  7. // GET <TMDB_BASE_URL>/tv/<tmdbId>?append_to_response=external_ids the details (status, counts,
  8. // overview, poster_path, seasons[])
  9. // + external_ids (imdb_id, tvdb_id)
  10. // GET <TMDB_BASE_URL>/tv/<tmdbId>?append_to_response=season/1,season/2… up to 20 seasons with their
  11. // episodes in ONE request
  12. // GET <TMDB_IMAGE_URL>/w342<poster_path> the poster → storage/mpackdb/posters/
  13. // Authorization: Bearer $TMDB_READ_TOKEN
  14. // GET <TVMAZE_BASE_URL>/lookup/shows?imdb=tt… (else ?thetvdb=<id>) the TVmaze id, ONLY when the show
  15. // has none (301 → /shows/<id>, 404 = unknown)
  16. // GET <TVMAZE_BASE_URL>/shows/<tvmzId>/episodes a SERIES' episodes on TVmaze (#15), see below
  17. // A MOVIE (`type = 'movie'`, 7814 of the migrated rows, 1 followed): only
  18. // GET <TMDB_BASE_URL>/movie/<tmdbId>?append_to_response=external_ids its imdb id + poster; no seasons,
  19. // status, overview (it is no TV show)
  20. //
  21. // EXTERNAL IDS (tracker.worldapi.org#12, the show page's link icons): a MISSING imdbId/tvdbId is filled from TMDB's
  22. // external_ids, a missing tvmzId from TVmaze's lookup — an id the show already has is NEVER overwritten.
  23. // RULES (ticket): new seasons/episodes are ADDED, existing ones UPDATED (title, summary, release) —
  24. // matched by tmdbId, never duplicated; NOTHING is deleted. An episode without a tmdbId (161 migrated ones)
  25. // is matched by its S/E number instead and gets the tmdbId. A season (no tmdbId in the migrated data) is
  26. // matched by (show, seasonNumber). A TMDB value that is empty/null never overwrites a stored one.
  27. // Season 0 ("Specials") is only synced for a show that already has a season 0 (else every followed show
  28. // would suddenly flood /my/unwatched with specials); a TMDB season with no episodes is not created.
  29. //
  30. // TVMAZE MERGE (tracker.worldapi.org#15): AFTER TMDB, a series with a tvmzId gets TVmaze's episode list (one request)
  31. // and TVmaze FILLS GAPS only — matched by season/episode number: a missing episode (and its season) is ADDED, an empty
  32. // title or air date of an existing one is FILLED; a value TMDB gave is never overwritten. A placeholder title
  33. // ("Episode 4", "TBA") counts as empty — and TMDB's own placeholder never overwrites a real title either (else the
  34. // two would flip it every day). When TMDB later lists the episode, the TMDB step adopts it by number (it has no tmdbId)
  35. // and TMDB's values win. TVmaze specials (no episode number) are skipped, season 0 only if the show has one. SAFETY:
  36. // when the two number a show differently (fewer than 80 % of the episodes both know air within a day of each other or
  37. // share the title — `numberingAgrees`), TVmaze is ignored for that show (`tvmazeSkipped`). Summaries are not taken
  38. // (TVmaze's are HTML; the ticket asks for episodes, air dates, titles).
  39. //
  40. // ADULT FLAG (mission 054): every details answer carries TMDB's `adult` — the sync, the import (search.hl) and the
  41. // BACKFILL below store it as show.adult (true/false; null = not known yet, hidden from the public lists, shows.hl
  42. // isPublicTitle). THE BACKFILL (`backfillTitle`, run one title per step by project.hl `backfillTick`, like the daily sync):
  43. // GET <TMDB_BASE_URL>/movie|tv/<tmdbId>?append_to_response=external_ids ONE request: adult, poster_path, imdb/tvdb id
  44. // GET <TMDB_IMAGE_URL>/w342<poster_path> the poster when its file is missing
  45. // for every title whose adult flag is unknown (no TVmaze, no seasons — the daily sync does those for followed shows). A
  46. // title TMDB answers 404 for (or one without tmdbId) gets `adultCheck` = why, stays unknown (hidden) and is not asked again.
  47. //
  48. // IDS ARE SELF-ASSIGNED (randomBytes, like watches.hl): hl:mpackdb's own generated @id can repeat within
  49. // one millisecond, which is what made update() lose rows (hybriel#113, fixed upstream, not in the vendored
  50. // build) — a table holding only unique ids is safe to update().
  51. //
  52. // ONE PROCESS PER STORAGE: hl:mpackdb does not coordinate two processes (hybriel#21) — the in-app run uses
  53. // the app's own open tables (imported from shows.hl); the tool only ever runs against a stopped app or a copy.
  54. //
  55. // Env: TMDB_READ_TOKEN (required unless TMDB_BASE_URL points at a fake), TMDB_BASE_URL
  56. // (https://api.themoviedb.org/3), TMDB_IMAGE_URL (https://image.tmdb.org/t/p) — the gate points both at its
  57. // fake TMDB (tests/browser.mjs).
  58. import { fetch } from 'hl:fetch'
  59. import { env } from 'hl:proc'
  60. import { now } from 'hl:time'
  61. import { randomBytes } from 'hl:crypto'
  62. import { writeFile, mkDir, exists } from 'hl:fs'
  63. import { showsTable, seasonsTable, episodesTable, storageDir } from './shows.hl'
  64. static envOr = (name, fallback) => {
  65. let v = env(name)
  66. return v != null && v.trim() != '' ? v.trim() : fallback
  67. }
  68. static tmdbBase = envOr('TMDB_BASE_URL', 'https://api.themoviedb.org/3')
  69. static tmdbImageBase = envOr('TMDB_IMAGE_URL', 'https://image.tmdb.org/t/p')
  70. static tmdbToken = envOr('TMDB_READ_TOKEN', '')
  71. static postersDir = storageDir + '/posters'
  72. static tvmazeBase = envOr('TVMAZE_BASE_URL', 'https://api.tvmaze.com')
  73. // the sync can run: a token is set (never printed) — the dev .env on Loreana has one, a gate sets its own
  74. static syncEnabled = tmdbToken != ''
  75. // TMDB allows 40 requests / 10 s: every runner waits this long per request it made before the next show
  76. static msPerRequest = 260
  77. // TVmaze allows 20 requests / 10 s (its API docs): 550 ms per lookup made
  78. static msPerTvmazeRequest = 550
  79. // how long a runner waits after a show before the next one (both runners: project.hl syncTick, tools/sync-tmdb.hl)
  80. static pauseMsAfter = (r) => { return r.requests * msPerRequest + r.tvmazeRequests * msPerTvmazeRequest }
  81. // one GET → the parsed JSON, or { failed = '<why>' }. A refused/timed-out fetch is an Error event (absorbed
  82. // by the runner's `on Error`), the fetch then yields null.
  83. static tmdbGet = (path) => {
  84. let r = fetch(tmdbBase + path, { headers = { 'authorization' = 'Bearer ' + tmdbToken 'accept' = 'application/json' 'user-agent' = 'tracker.worldapi.org (tmdb sync)' } timeoutMs = 20000 })
  85. if (r == null || r.status == null || r.status == 0) { return { failed = 'no answer for ' + path } }
  86. if (r.status != 200) { return { failed = 'HTTP ' + r.status + ' for ' + path status = r.status } }
  87. let j = r.json()
  88. if (j == null) { return { failed = 'no JSON for ' + path } }
  89. return j
  90. }
  91. static textOr = (v) => { return v != null && hlTypeName(v) == 'String' && v.trim() != '' ? v : null }
  92. // ---- external ids (tracker.worldapi.org#12) -----------------------------------------------------
  93. static hasId = (v) => { return v != null && ('' + v).trim() != '' }
  94. // an IMDb id as TMDB gives it ('tt' + digits), else null
  95. static imdbOr = (v) => {
  96. let t = textOr(v)
  97. if (t == null || t.length < 3 || t.slice(0, 2) != 'tt') { return null }
  98. let i = 2
  99. while (i < t.length) {
  100. if (!'0123456789'.includes(t[i])) { return null }
  101. i = i + 1
  102. }
  103. return t
  104. }
  105. // a positive numeric id (TMDB's tvdb_id, TVmaze's id), else null
  106. static numIdOr = (v) => { return v != null && hlTypeName(v) == 'Number' && v > 0 ? v : null }
  107. // TVmaze's lookup by one external id: { id } (null = TVmaze doesn't know it, 404) or { failed }. hl:fetch follows the
  108. // 301 to /shows/<id> by itself, so a hit answers the show's own JSON.
  109. static tvmazeLookup = (param, value) => {
  110. let r = fetch(tvmazeBase + '/lookup/shows?' + param + '=' + value, { headers = { 'accept' = 'application/json' 'user-agent' = 'tracker.worldapi.org (tmdb sync)' } timeoutMs = 20000 })
  111. if (r == null || r.status == null || r.status == 0) { return { failed = 'TVmaze: no answer for ' + param + '=' + value } }
  112. if (r.status == 404) { return { id = null } }
  113. if (r.status != 200) { return { failed = 'TVmaze: HTTP ' + r.status + ' for ' + param + '=' + value } }
  114. let j = r.json()
  115. if (j == null || numIdOr(j.id) == null) { return { failed = 'TVmaze: no id for ' + param + '=' + value } }
  116. return { id = j.id }
  117. }
  118. // the ids to SET on a show from TMDB's external_ids (+ TVmaze for a series): { imdbId, tvdbId, tvmzId } — each the
  119. // show's own value when it has one (never overwritten), else the found one, else null — plus { filled, tvmazeRequests,
  120. // failed }. TVmaze is asked by imdb, then by thetvdb, only while the show (after TMDB) still has no tvmzId — and only
  121. // when `askTvmaze` (the adult backfill does not: one request per title)
  122. static externalIdsFor = (show, ext, isSeries, askTvmaze) => {
  123. let out = { imdbId = show.imdbId tvdbId = show.tvdbId tvmzId = show.tvmzId filled = 0 tvmazeRequests = 0 failed = null }
  124. let e = ext != null ? ext : {}
  125. if (!hasId(out.imdbId) && imdbOr(e.imdb_id) != null) { out.imdbId = e.imdb_id out.filled = out.filled + 1 }
  126. if (!isSeries) { return out }
  127. if (!hasId(out.tvdbId) && numIdOr(e.tvdb_id) != null) { out.tvdbId = e.tvdb_id out.filled = out.filled + 1 }
  128. if (hasId(out.tvmzId) || !askTvmaze) { return out }
  129. let tries = []
  130. if (imdbOr('' + out.imdbId) != null) { tries.push({ param = 'imdb' value = '' + out.imdbId }) }
  131. if (numIdOr(out.tvdbId) != null) { tries.push({ param = 'thetvdb' value = '' + out.tvdbId }) }
  132. for (t of tries) {
  133. if (!hasId(out.tvmzId) && out.failed == null) {
  134. out.tvmazeRequests = out.tvmazeRequests + 1
  135. let found = tvmazeLookup(t.param, t.value)
  136. if (found.failed != null) { out.failed = found.failed } else if (found.id != null) { out.tvmzId = found.id out.filled = out.filled + 1 }
  137. }
  138. }
  139. return out
  140. }
  141. // ---- TVmaze merge (tracker.worldapi.org#15) ------------------------------------------------------
  142. // a title that is none yet: empty, "TBA"/"TBD", or "Episode <n>" (what TMDB and TVmaze name an untitled episode; also
  143. // French/German/Spanish: real data has TVmaze "Épisode 1" where TMDB says "Episode 1")
  144. static placeholderWords = ['episode', 'épisode', 'Épisode', 'folge', 'episodio']
  145. static isPlaceholderTitle = (v) => {
  146. let t = textOr(v)
  147. if (t == null) { return true }
  148. let l = t.trim().toLowerCase()
  149. if (l == 'tba' || l == 'tbd' || l == 'tba.' || l == 'to be announced') { return true }
  150. let parts = l.split(' ')
  151. if (parts.length != 2 || !placeholderWords.includes(parts[0]) || parts[1].length == 0) { return false }
  152. let i = 0
  153. while (i < parts[1].length) {
  154. if (!'0123456789'.includes(parts[1][i])) { return false }
  155. i = i + 1
  156. }
  157. return true
  158. }
  159. // 'YYYY-MM-DD' → a day number (only differences matter), null when it is no such date
  160. static dayNumber = (v) => {
  161. let t = textOr(v)
  162. if (t == null || t.length < 10) { return null }
  163. let y = toNumber(t.slice(0, 4))
  164. let m = toNumber(t.slice(5, 7))
  165. let d = toNumber(t.slice(8, 10))
  166. if (y == null || m == null || d == null || m < 1 || m > 12) { return null }
  167. let before = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
  168. let py = m <= 2 ? y - 1 : y
  169. let leaps = (py - py % 4) / 4 - (py - py % 100) / 100 + (py - py % 400) / 400
  170. return y * 365 + leaps + before[m - 1] + d
  171. }
  172. static daysApart = (a, b) => {
  173. let x = dayNumber(a)
  174. let y = dayNumber(b)
  175. if (x == null || y == null) { return null }
  176. return x > y ? x - y : y - x
  177. }
  178. // TVmaze's episode list of one show: a list, or { failed }
  179. static tvmazeEpisodes = (tvmzId) => {
  180. let r = fetch(tvmazeBase + '/shows/' + tvmzId + '/episodes', { headers = { 'accept' = 'application/json' 'user-agent' = 'tracker.worldapi.org (tmdb sync)' } timeoutMs = 20000 })
  181. if (r == null || r.status == null || r.status == 0) { return { failed = 'TVmaze: no answer for episodes of ' + tvmzId } }
  182. if (r.status != 200) { return { failed = 'TVmaze: HTTP ' + r.status + ' for episodes of ' + tvmzId } }
  183. let j = r.json()
  184. if (j == null || j.length == null) { return { failed = 'TVmaze: no episode list for ' + tvmzId } }
  185. return { list = j }
  186. }
  187. // 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
  188. // of each other OR carry the same title (a streaming show TVmaze dates all on its release day, TMDB week by week, still
  189. // agrees by title). A show we have episodes of but TVmaze numbers none of the same (e.g. seasons by year) never agrees.
  190. // Answers { known, compared, agree, ok }.
  191. static sameTitle = (a, b) => { return !isPlaceholderTitle(a) && !isPlaceholderTitle(b) && a.trim().toLowerCase() == b.trim().toLowerCase() }
  192. static numberingAgrees = (epByNumber, list) => {
  193. let out = { known = 0 compared = 0 agree = 0 ok = true }
  194. let localCount = epByNumber.keys().length
  195. for (te of list) {
  196. let e = epByNumber[te.season + 'x' + te.number]
  197. if (e != null) {
  198. out.known = out.known + 1
  199. let apart = daysApart(e.release, te.airdate)
  200. let titled = !isPlaceholderTitle(e.title) && !isPlaceholderTitle(te.name)
  201. if (apart != null || titled) {
  202. out.compared = out.compared + 1
  203. if ((apart != null && apart <= 1) || sameTitle(e.title, te.name)) { out.agree = out.agree + 1 }
  204. }
  205. }
  206. }
  207. out.ok = !((localCount > 0 && out.known == 0) || (out.compared > 0 && out.agree * 5 < out.compared * 4))
  208. return out
  209. }
  210. // Merge TVmaze's episodes of a series into what we have AFTER the TMDB step (gaps only, rules at the top).
  211. // `seasonsByNumber` / `epByNumber` = the show's seasons by number and episodes by '<s>x<e>', as the TMDB step left them.
  212. // Answers { tvmazeRequests, newSeasons, newEpisodes, filledEpisodes, skipped, errors, error, showSeasons }.
  213. static mergeTvmaze = (title, showKey, tvmzId, seasonsIn, episodesIn, showSeasonsIn) => {
  214. let seasonsByNumber = seasonsIn
  215. let epByNumber = episodesIn
  216. let out = { tvmazeRequests = 1 newSeasons = 0 newEpisodes = 0 filledEpisodes = 0 skipped = 0 errors = 0 error = '' showSeasons = showSeasonsIn }
  217. let got = tvmazeEpisodes(tvmzId)
  218. if (got.failed != null) { out.errors = 1 out.error = title + ': ' + got.failed return out }
  219. // the numbered episodes TVmaze has in seasons we take (no specials; season 0 only when the show has one)
  220. let list = []
  221. for (te of got.list) {
  222. if (numIdOr(te.season) != null && numIdOr(te.number) != null) { list.push(te) }
  223. else if (te.season == 0 && numIdOr(te.number) != null && seasonsByNumber['0'] != null) { list.push(te) }
  224. }
  225. if (!numberingAgrees(epByNumber, list).ok) { out.skipped = 1 return out }
  226. let added = {}
  227. let addedFirstDate = {}
  228. for (te of list) {
  229. let key = te.season + 'x' + te.number
  230. let e = epByNumber[key]
  231. if (e == null) {
  232. let seasonKey = seasonsByNumber['' + te.season] != null ? seasonsByNumber['' + te.season].id : null
  233. if (seasonKey == null) {
  234. if (added['' + te.season] == null) { added['' + te.season] = [] addedFirstDate['' + te.season] = textOr(te.airdate) seasonsByNumber['' + te.season] = { id = newId() isNew = true } }
  235. seasonKey = seasonsByNumber['' + te.season].id
  236. }
  237. let fresh = { id = newId() episodeNumber = te.number seasonNumber = te.season
  238. title = textOr(te.name) summary = '' release = textOr(te.airdate)
  239. show = showKey season = seasonKey tmdbId = null tvmzId = te.id oldId = 'tvmaze-episode:' + te.id }
  240. if (episodesTable.put(fresh) == null) {
  241. out.errors = out.errors + 1
  242. out.error = title + ': TVmaze episode ' + te.id + ' not stored: ' + episodesTable.lastError()
  243. } else {
  244. if (added['' + te.season] == null) { added['' + te.season] = [] }
  245. let l = added['' + te.season]
  246. l.push(fresh.id)
  247. added['' + te.season] = l
  248. epByNumber[key] = fresh
  249. out.newEpisodes = out.newEpisodes + 1
  250. }
  251. } else {
  252. let changed = false
  253. if (isPlaceholderTitle(e.title) && !isPlaceholderTitle(te.name)) { e.title = te.name changed = true }
  254. if (textOr(e.release) == null && textOr(te.airdate) != null) { e.release = te.airdate changed = true }
  255. if (changed) {
  256. if (episodesTable.update(e.id, e) == null) {
  257. out.errors = out.errors + 1
  258. out.error = title + ': episode ' + e.id + ' not filled from TVmaze: ' + episodesTable.lastError()
  259. } else {
  260. epByNumber[key] = e
  261. out.filledEpisodes = out.filledEpisodes + 1
  262. }
  263. }
  264. }
  265. }
  266. // the seasons that got episodes: an existing one is updated, a new one stored and linked to the show
  267. let showSeasons = []
  268. for (sid of showSeasonsIn) { showSeasons.push(sid) }
  269. for (n of added.keys()) {
  270. let ids = added[n]
  271. let s = seasonsByNumber[n]
  272. if (ids.length > 0 && s.isNew == true) {
  273. let season = { id = s.id seasonNumber = toNumber(n) title = 'Season ' + n release = addedFirstDate[n] summary = ''
  274. show = showKey episodes = ids oldId = 'tvmaze-season:' + tvmzId + ':' + n }
  275. if (seasonsTable.put(season) == null) {
  276. out.errors = out.errors + 1
  277. out.error = title + ': TVmaze season ' + n + ' not stored: ' + seasonsTable.lastError()
  278. } else {
  279. showSeasons.push(season.id)
  280. out.newSeasons = out.newSeasons + 1
  281. }
  282. } else if (ids.length > 0) {
  283. let season = seasonsTable.fetch(s.id)
  284. let eps = []
  285. for (eid of season.episodes) { eps.push(eid) }
  286. for (eid of ids) { eps.push(eid) }
  287. season.episodes = eps
  288. if (seasonsTable.update(season.id, season) == null) {
  289. out.errors = out.errors + 1
  290. out.error = title + ': season ' + season.id + ' not updated: ' + seasonsTable.lastError()
  291. }
  292. }
  293. }
  294. out.showSeasons = showSeasons
  295. return out
  296. }
  297. // ---- posters ----------------------------------------------------------------------------------
  298. // the extension of a TMDB poster_path ('/abc.jpg' → 'jpg'); only the ones the poster route knows
  299. static posterExt = (posterPath) => {
  300. let parts = posterPath.split('.')
  301. let ext = parts[parts.length - 1].toLowerCase()
  302. if (ext == 'jpg' || ext == 'jpeg' || ext == 'png' || ext == 'webp') { return ext }
  303. return null
  304. }
  305. // downloads the w342 poster when the file is missing or TMDB has a new one; answers { requests, saved, failed }
  306. // and sets show.image / show.tmdbPoster on the (caller's) record — the file is <oldId>.<ext>, the name
  307. // shows.hl posterName() already builds, served by project.hl's /posters/:name route
  308. static syncPoster = (show, posterPath) => {
  309. let out = { requests = 0 saved = 0 failed = null image = show.image tmdbPoster = show.tmdbPoster }
  310. if (textOr(posterPath) == null || show.oldId == null) { return out }
  311. let ext = posterExt(posterPath)
  312. if (ext == null) { return out }
  313. let file = postersDir + '/' + show.oldId + '.' + ext
  314. if (exists(file) && show.tmdbPoster == posterPath && show.image == ext) { return out }
  315. out.requests = 1
  316. let r = fetch(tmdbImageBase + '/w342' + posterPath, { headers = { 'user-agent' = 'tracker.worldapi.org (tmdb sync)' } timeoutMs = 20000 })
  317. if (r == null || r.status != 200 || r.body == null || r.body.length == 0) {
  318. out.failed = 'poster ' + posterPath + ': ' + (r == null ? 'no answer' : 'HTTP ' + r.status)
  319. return out
  320. }
  321. mkDir(postersDir)
  322. writeFile(file, toBytes(r.body), 420)
  323. out.saved = 1
  324. out.image = ext
  325. out.tmdbPoster = posterPath
  326. return out
  327. }
  328. // ---- one show -----------------------------------------------------------------------------------
  329. static newId = () => { return randomBytes(8, 'hex') }
  330. // TMDB's adult flag of a details/search answer: true / false, null when it has none (mission 054)
  331. static adultOf = (j) => {
  332. if (j == null) { return null }
  333. if (j.adult == true) { return true }
  334. if (j.adult == false) { return false }
  335. return null
  336. }
  337. // which TMDB seasons to sync: every numbered one with episodes; 0 only when the show has a season 0 already
  338. static wantedSeasonNumbers = (details, localByNumber) => {
  339. let out = []
  340. if (details.seasons == null) { return out }
  341. for (s of details.seasons) {
  342. let n = s.season_number
  343. if (n != null && (n > 0 || localByNumber['' + n] != null) && (s.episode_count == null || s.episode_count > 0 || localByNumber['' + n] != null)) { out.push(n) }
  344. }
  345. return out
  346. }
  347. // the seasons in batches of 20 (TMDB's append_to_response limit)
  348. static batchesOf = (list, size) => {
  349. let out = []
  350. let cur = []
  351. for (x of list) {
  352. cur.push(x)
  353. if (cur.length == size) { out.push(cur) cur = [] }
  354. }
  355. if (cur.length > 0) { out.push(cur) }
  356. return out
  357. }
  358. // fields of an episode/season from TMDB that differ from the stored record (null/empty never overwrites)
  359. static changedText = (stored, fresh) => { return fresh != null && fresh != stored }
  360. // a MOVIE: one request — its imdb id (if missing) and its poster; nothing else of the record is touched
  361. static syncMovie = (show, res) => {
  362. let out = res
  363. out.requests = 1
  364. let details = tmdbGet('/movie/' + show.tmdbId + '?append_to_response=external_ids')
  365. if (details.failed != null) { out.errors = 1 out.error = show.title + ': ' + details.failed return out }
  366. let ids = externalIdsFor(show, details.external_ids, false, false)
  367. out.ids = ids.filled
  368. let poster = syncPoster(show, details.poster_path)
  369. out.requests = out.requests + poster.requests
  370. out.posters = poster.saved
  371. if (poster.failed != null) { out.errors = out.errors + 1 out.error = show.title + ': ' + poster.failed }
  372. let m = show
  373. m.imdbId = ids.imdbId
  374. m.image = poster.image
  375. m.tmdbPoster = poster.tmdbPoster
  376. if (adultOf(details) != null) { m.adult = adultOf(details) }
  377. m.tmdbSync = now()
  378. if (showsTable.update(m.id, m) == null) { out.errors = out.errors + 1 out.error = show.title + ': movie not updated: ' + showsTable.lastError() }
  379. return out
  380. }
  381. // Sync ONE show (by our id). Answers counts: { requests, tvmazeRequests, newSeasons, newEpisodes, updatedEpisodes,
  382. // updatedSeasons, posters, ids, tvmazeEpisodes, tvmazeSeasons, tvmazeFilled, tvmazeSkipped, errors, error }. Never throws for a TMDB failure — it is counted and the show left as is.
  383. static syncShow = (showId) => {
  384. let res = { 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 = '' }
  385. let show = showsTable.fetch(showId)
  386. if (show == null) { res.errors = 1 res.error = 'no show ' + showId return res }
  387. if (show.tmdbId == null) { res.errors = 1 res.error = show.title + ': no tmdbId' return res }
  388. if (show.type == 'movie') { return syncMovie(show, res) }
  389. res.requests = 1
  390. let details = tmdbGet('/tv/' + show.tmdbId + '?append_to_response=external_ids')
  391. if (details.failed != null) { res.errors = 1 res.error = show.title + ': ' + details.failed return res }
  392. // what we have: seasons by number, episodes by tmdbId and by S/E number
  393. let seasonsByNumber = {}
  394. let epByTmdb = {}
  395. let epByNumber = {}
  396. for (sid of show.seasons) {
  397. let s = seasonsTable.fetch(sid)
  398. if (s != null) {
  399. seasonsByNumber['' + s.seasonNumber] = s
  400. for (eid of s.episodes) {
  401. let e = episodesTable.fetch(eid)
  402. if (e != null) {
  403. if (e.tmdbId != null) { epByTmdb['' + e.tmdbId] = e }
  404. epByNumber[e.seasonNumber + 'x' + e.episodeNumber] = e
  405. }
  406. }
  407. }
  408. }
  409. // plain locals for the record literals below: inside a literal, an entry named `show`/`season` would shadow
  410. // the variable for every later entry (SPEC "A literal's entries are evaluated in order")
  411. let showKey = show.id
  412. let showTmdb = show.tmdbId
  413. let showSeasons = []
  414. for (sid of show.seasons) { showSeasons.push(sid) }
  415. let showChanged = false
  416. for (batch of batchesOf(wantedSeasonNumbers(details, seasonsByNumber), 20)) {
  417. let names = []
  418. for (n of batch) { names.push('season/' + n) }
  419. res.requests = res.requests + 1
  420. let withSeasons = tmdbGet('/tv/' + show.tmdbId + '?append_to_response=' + names.join(','))
  421. if (withSeasons.failed != null) {
  422. res.errors = res.errors + 1
  423. res.error = show.title + ': ' + withSeasons.failed
  424. } else {
  425. for (n of batch) {
  426. let ts = withSeasons['season/' + n]
  427. if (ts != null && ts.episodes != null) {
  428. let season = seasonsByNumber['' + n]
  429. let seasonIsNew = season == null
  430. let seasonChanged = false
  431. if (seasonIsNew) {
  432. season = { id = newId() seasonNumber = n title = textOr(ts.name) != null ? ts.name : 'Season ' + n
  433. release = textOr(ts.air_date) summary = textOr(ts.overview) != null ? ts.overview : ''
  434. show = showKey episodes = [] oldId = 'tmdb-season:' + showTmdb + ':' + n }
  435. } else {
  436. if (changedText(season.title, textOr(ts.name))) { season.title = ts.name seasonChanged = true }
  437. if (changedText(season.summary, textOr(ts.overview))) { season.summary = ts.overview seasonChanged = true }
  438. if (changedText(season.release, textOr(ts.air_date))) { season.release = ts.air_date seasonChanged = true }
  439. }
  440. let seasonKey = season.id
  441. let seasonEpisodes = []
  442. for (eid of season.episodes) { seasonEpisodes.push(eid) }
  443. for (te of ts.episodes) {
  444. if (te.id != null && te.episode_number != null) {
  445. let e = epByTmdb['' + te.id]
  446. if (e == null) {
  447. let byNum = epByNumber[n + 'x' + te.episode_number]
  448. if (byNum != null && byNum.tmdbId == null) { e = byNum }
  449. }
  450. if (e == null) {
  451. let fresh = { id = newId() episodeNumber = te.episode_number seasonNumber = n
  452. title = textOr(te.name) summary = textOr(te.overview) != null ? te.overview : '' release = textOr(te.air_date)
  453. show = showKey season = seasonKey tmdbId = te.id oldId = 'tmdb-episode:' + te.id }
  454. let putId = episodesTable.put(fresh)
  455. if (putId == null) {
  456. res.errors = res.errors + 1
  457. res.error = show.title + ': episode ' + te.id + ' not stored: ' + episodesTable.lastError()
  458. } else {
  459. seasonEpisodes.push(fresh.id)
  460. epByTmdb['' + te.id] = fresh
  461. epByNumber[n + 'x' + te.episode_number] = fresh
  462. res.newEpisodes = res.newEpisodes + 1
  463. seasonChanged = true
  464. }
  465. } else {
  466. let changed = false
  467. if (e.tmdbId == null) { e.tmdbId = te.id changed = true }
  468. // #15: TMDB's "Episode 4" never replaces a real title (one TVmaze filled, or the migrated one)
  469. if (changedText(e.title, textOr(te.name)) && !(isPlaceholderTitle(te.name) && !isPlaceholderTitle(e.title))) { e.title = te.name changed = true }
  470. if (changedText(e.summary, textOr(te.overview))) { e.summary = te.overview changed = true }
  471. if (changedText(e.release, textOr(te.air_date))) { e.release = te.air_date changed = true }
  472. if (changed) {
  473. if (episodesTable.update(e.id, e) == null) {
  474. res.errors = res.errors + 1
  475. res.error = show.title + ': episode ' + e.id + ' not updated: ' + episodesTable.lastError()
  476. } else {
  477. epByTmdb['' + e.tmdbId] = e
  478. epByNumber[e.seasonNumber + 'x' + e.episodeNumber] = e
  479. res.updatedEpisodes = res.updatedEpisodes + 1
  480. }
  481. }
  482. }
  483. }
  484. }
  485. season.episodes = seasonEpisodes
  486. if (seasonIsNew) {
  487. if (seasonsTable.put(season) == null) {
  488. res.errors = res.errors + 1
  489. res.error = show.title + ': season ' + n + ' not stored: ' + seasonsTable.lastError()
  490. } else {
  491. seasonsByNumber['' + n] = season
  492. showSeasons.push(season.id)
  493. showChanged = true
  494. res.newSeasons = res.newSeasons + 1
  495. }
  496. } else if (seasonChanged) {
  497. if (seasonsTable.update(season.id, season) == null) {
  498. res.errors = res.errors + 1
  499. res.error = show.title + ': season ' + season.id + ' not updated: ' + seasonsTable.lastError()
  500. } else {
  501. res.updatedSeasons = res.updatedSeasons + 1
  502. }
  503. }
  504. }
  505. }
  506. }
  507. }
  508. // the external ids (tracker.worldapi.org#12): missing ones from TMDB's external_ids, a missing TVmaze id from TVmaze
  509. let ids = externalIdsFor(show, details.external_ids, true, true)
  510. res.ids = ids.filled
  511. res.tvmazeRequests = ids.tvmazeRequests
  512. if (ids.failed != null) { res.errors = res.errors + 1 res.error = show.title + ': ' + ids.failed }
  513. // tracker.worldapi.org#15: what TVmaze has and TMDB not yet (new episodes/seasons, empty titles and air dates)
  514. if (numIdOr(ids.tvmzId) != null) {
  515. let tvm = mergeTvmaze(show.title, showKey, ids.tvmzId, seasonsByNumber, epByNumber, showSeasons)
  516. res.tvmazeRequests = res.tvmazeRequests + tvm.tvmazeRequests
  517. res.tvmazeEpisodes = tvm.newEpisodes
  518. res.tvmazeSeasons = tvm.newSeasons
  519. res.tvmazeFilled = tvm.filledEpisodes
  520. res.tvmazeSkipped = tvm.skipped
  521. if (tvm.errors > 0) { res.errors = res.errors + tvm.errors res.error = tvm.error }
  522. showSeasons = tvm.showSeasons
  523. }
  524. // the show itself: status, counts, TMDB's overview, the poster, when it was synced
  525. let poster = syncPoster(show, details.poster_path)
  526. res.requests = res.requests + poster.requests
  527. res.posters = poster.saved
  528. if (poster.failed != null) { res.errors = res.errors + 1 res.error = show.title + ': ' + poster.failed }
  529. show.seasons = showSeasons
  530. if (textOr(details.status) != null) { show.status = details.status }
  531. if (details.number_of_seasons != null) { show.seasonsCount = details.number_of_seasons }
  532. if (details.number_of_episodes != null) { show.episodesCount = details.number_of_episodes }
  533. if (textOr(details.overview) != null) { show.tmdbSummary = details.overview }
  534. show.image = poster.image
  535. show.tmdbPoster = poster.tmdbPoster
  536. show.imdbId = ids.imdbId
  537. show.tvdbId = ids.tvdbId
  538. show.tvmzId = ids.tvmzId
  539. if (adultOf(details) != null) { show.adult = adultOf(details) }
  540. show.tmdbSync = now()
  541. if (showsTable.update(show.id, show) == null) { res.errors = res.errors + 1 res.error = show.title + ': show not updated: ' + showsTable.lastError() }
  542. return res
  543. }
  544. // ---- the adult backfill (mission 054) ------------------------------------------------------------
  545. // a title still to ask: its adult flag unknown, it has a tmdbId and TMDB never answered "no such title" for it
  546. static needsAdultFlag = (show) => { return show.adult != true && show.adult != false && show.adultCheck == null && show.tmdbId != null }
  547. // the ids of every title the backfill still has to ask, in table order (read once when the backfill starts — so after a
  548. // restart it goes on with what is left)
  549. static adultBackfillQueue = () => {
  550. let out = []
  551. for (s of showsTable.find(null, null)) { if (needsAdultFlag(s)) { out.push(s.id) } }
  552. return out
  553. }
  554. // ONE title: one TMDB details request (adult, poster_path, external ids) + its poster when the file is missing. Answers
  555. // { requests (TMDB API requests: what the pause counts), posters, adult (true / false / null), ids, errors, error, retry
  556. // (a passing failure: no answer, 429, 5xx — ask again later) }. A 404 marks the title `adultCheck` (never asked again).
  557. static backfillTitle = (showId) => {
  558. let out = { requests = 0 posters = 0 adult = null ids = 0 errors = 0 error = '' retry = false }
  559. let show = showsTable.fetch(showId)
  560. if (show == null || !needsAdultFlag(show)) { return out }
  561. let movie = show.type == 'movie'
  562. out.requests = 1
  563. let details = tmdbGet((movie ? '/movie/' : '/tv/') + show.tmdbId + '?append_to_response=external_ids')
  564. if (details.failed != null) {
  565. out.errors = 1
  566. out.error = show.title + ': ' + details.failed
  567. if (details.status == 404) {
  568. show.adultCheck = 'TMDB 404'
  569. showsTable.update(show.id, show)
  570. } else { out.retry = true }
  571. return out
  572. }
  573. let ids = externalIdsFor(show, details.external_ids, !movie, false)
  574. out.ids = ids.filled
  575. let poster = syncPoster(show, details.poster_path)
  576. out.posters = poster.saved
  577. if (poster.failed != null) { out.errors = out.errors + 1 out.error = show.title + ': ' + poster.failed }
  578. out.adult = adultOf(details)
  579. if (out.adult == null) { show.adultCheck = 'TMDB gave no adult flag' } else { show.adult = out.adult }
  580. show.imdbId = ids.imdbId
  581. show.tvdbId = ids.tvdbId
  582. show.image = poster.image
  583. show.tmdbPoster = poster.tmdbPoster
  584. if (showsTable.update(show.id, show) == null) { out.errors = out.errors + 1 out.error = show.title + ': not updated: ' + showsTable.lastError() }
  585. return out
  586. }
  587. // flush the three tables' index/meta state to disk (after a run, and every few shows)
  588. static persistSync = () => {
  589. showsTable.persist()
  590. seasonsTable.persist()
  591. episodesTable.persist()
  592. return true
  593. }
  594. // totals of a run: the per-show counts added up
  595. static 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 } }
  596. static addTotals = (t, r) => {
  597. let o = t
  598. o.shows = o.shows + 1
  599. o.requests = o.requests + r.requests
  600. o.tvmazeRequests = o.tvmazeRequests + r.tvmazeRequests
  601. o.ids = o.ids + r.ids
  602. o.newSeasons = o.newSeasons + r.newSeasons
  603. o.newEpisodes = o.newEpisodes + r.newEpisodes
  604. o.updatedEpisodes = o.updatedEpisodes + r.updatedEpisodes
  605. o.updatedSeasons = o.updatedSeasons + r.updatedSeasons
  606. o.posters = o.posters + r.posters
  607. o.tvmazeEpisodes = o.tvmazeEpisodes + r.tvmazeEpisodes
  608. o.tvmazeSeasons = o.tvmazeSeasons + r.tvmazeSeasons
  609. o.tvmazeFilled = o.tvmazeFilled + r.tvmazeFilled
  610. o.tvmazeSkipped = o.tvmazeSkipped + r.tvmazeSkipped
  611. o.errors = o.errors + r.errors
  612. return o
  613. }
  614. static totalsLine = (t) => {
  615. return 'shows=' + t.shows + ' newSeasons=' + t.newSeasons + ' newEpisodes=' + t.newEpisodes + ' updatedEpisodes=' + t.updatedEpisodes
  616. + ' updatedSeasons=' + t.updatedSeasons + ' posters=' + t.posters + ' ids=' + t.ids + ' requests=' + t.requests + ' tvmazeRequests=' + t.tvmazeRequests + ' errors=' + t.errors
  617. + ' tvmazeEpisodes=' + t.tvmazeEpisodes + ' tvmazeSeasons=' + t.tvmazeSeasons + ' tvmazeFilled=' + t.tvmazeFilled + ' tvmazeSkipped=' + t.tvmazeSkipped
  618. }

Branches

Latest commits

  • 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
  • b105bcd8tracker#11: Hybriel master ff51cf46 (checks no longer vanish), mobile-first styles, carets, follow button, sign-in modal, inverted check, orange castmre
  • 31b758aatracker#10: installable app (manifest, service worker, offline shell), own icon + faviconmre
  • 2fa9d997tracker#9: TMDB sync (followed shows: seasons, episodes, posters), tools/sync-tmdb.hl + daily run 04:00 UTC, fake TMDB in gatemre
  • 49e1f61edeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 54070a4etracker#8: /my/unwatched + /my/schedule (301 from old), S01E01, title (year), 1 episode, watched-set lookup (unwatched 15s -> 1s)mre
  • 3251488atracker#7: /my/shows (followed shows, newest follow first, poster, title, last watched SxxEyy); gate can take screenshots (TRACKER_GATE_SHOTS)mre
  • 44b7d9f9tracker#6: /schedule — upcoming episodes of followed shows, soonest firstmre