gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitb105bcd8b105bcd8tracker#11: Hybriel master ff51cf46 (checks no longer vanish), mobile-first styles, carets, follow button, sign-in modal, inverted check, orange castmreb105bcd8/tmdbsync.hl

14.0 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> the details (status, counts,
  8. // overview, poster_path, seasons[])
  9. // GET <TMDB_BASE_URL>/tv/<tmdbId>?append_to_response=season/1,season/2… up to 20 seasons with their
  10. // episodes in ONE request
  11. // GET <TMDB_IMAGE_URL>/w342<poster_path> the poster → storage/mpackdb/posters/
  12. // Authorization: Bearer $TMDB_READ_TOKEN
  13. //
  14. // RULES (ticket): new seasons/episodes are ADDED, existing ones UPDATED (title, summary, release) —
  15. // matched by tmdbId, never duplicated; NOTHING is deleted. An episode without a tmdbId (161 migrated ones)
  16. // is matched by its S/E number instead and gets the tmdbId. A season (no tmdbId in the migrated data) is
  17. // matched by (show, seasonNumber). A TMDB value that is empty/null never overwrites a stored one.
  18. // Season 0 ("Specials") is only synced for a show that already has a season 0 (else every followed show
  19. // would suddenly flood /my/unwatched with specials); a TMDB season with no episodes is not created.
  20. //
  21. // IDS ARE SELF-ASSIGNED (randomBytes, like watches.hl): hl:mpackdb's own generated @id can repeat within
  22. // one millisecond, which is what made update() lose rows (hybriel#113, fixed upstream, not in the vendored
  23. // build) — a table holding only unique ids is safe to update().
  24. //
  25. // ONE PROCESS PER STORAGE: hl:mpackdb does not coordinate two processes (hybriel#21) — the in-app run uses
  26. // the app's own open tables (imported from shows.hl); the tool only ever runs against a stopped app or a copy.
  27. //
  28. // Env: TMDB_READ_TOKEN (required unless TMDB_BASE_URL points at a fake), TMDB_BASE_URL
  29. // (https://api.themoviedb.org/3), TMDB_IMAGE_URL (https://image.tmdb.org/t/p) — the gate points both at its
  30. // fake TMDB (tests/browser.mjs).
  31. import { fetch } from 'hl:fetch'
  32. import { env } from 'hl:proc'
  33. import { now } from 'hl:time'
  34. import { randomBytes } from 'hl:crypto'
  35. import { writeFile, mkDir, exists } from 'hl:fs'
  36. import { showsTable, seasonsTable, episodesTable, storageDir } from './shows.hl'
  37. static envOr = (name, fallback) => {
  38. let v = env(name)
  39. return v != null && v.trim() != '' ? v.trim() : fallback
  40. }
  41. static tmdbBase = envOr('TMDB_BASE_URL', 'https://api.themoviedb.org/3')
  42. static tmdbImageBase = envOr('TMDB_IMAGE_URL', 'https://image.tmdb.org/t/p')
  43. static tmdbToken = envOr('TMDB_READ_TOKEN', '')
  44. static postersDir = storageDir + '/posters'
  45. // the sync can run: a token is set (never printed) — the dev .env on Loreana has one, a gate sets its own
  46. static syncEnabled = tmdbToken != ''
  47. // TMDB allows 40 requests / 10 s: every runner waits this long per request it made before the next show
  48. static msPerRequest = 260
  49. // one GET → the parsed JSON, or { failed = '<why>' }. A refused/timed-out fetch is an Error event (absorbed
  50. // by the runner's `on Error`), the fetch then yields null.
  51. static tmdbGet = (path) => {
  52. let r = fetch(tmdbBase + path, { headers = { 'authorization' = 'Bearer ' + tmdbToken 'accept' = 'application/json' 'user-agent' = 'tracker.worldapi.org (tmdb sync)' } timeoutMs = 20000 })
  53. if (r == null || r.status == null || r.status == 0) { return { failed = 'no answer for ' + path } }
  54. if (r.status != 200) { return { failed = 'HTTP ' + r.status + ' for ' + path } }
  55. let j = r.json()
  56. if (j == null) { return { failed = 'no JSON for ' + path } }
  57. return j
  58. }
  59. static textOr = (v) => { return v != null && hlTypeName(v) == 'String' && v.trim() != '' ? v : null }
  60. // ---- posters ----------------------------------------------------------------------------------
  61. // the extension of a TMDB poster_path ('/abc.jpg' → 'jpg'); only the ones the poster route knows
  62. static posterExt = (posterPath) => {
  63. let parts = posterPath.split('.')
  64. let ext = parts[parts.length - 1].toLowerCase()
  65. if (ext == 'jpg' || ext == 'jpeg' || ext == 'png' || ext == 'webp') { return ext }
  66. return null
  67. }
  68. // downloads the w342 poster when the file is missing or TMDB has a new one; answers { requests, saved, failed }
  69. // and sets show.image / show.tmdbPoster on the (caller's) record — the file is <oldId>.<ext>, the name
  70. // shows.hl posterName() already builds, served by project.hl's /posters/:name route
  71. static syncPoster = (show, posterPath) => {
  72. let out = { requests = 0 saved = 0 failed = null image = show.image tmdbPoster = show.tmdbPoster }
  73. if (textOr(posterPath) == null || show.oldId == null) { return out }
  74. let ext = posterExt(posterPath)
  75. if (ext == null) { return out }
  76. let file = postersDir + '/' + show.oldId + '.' + ext
  77. if (exists(file) && show.tmdbPoster == posterPath && show.image == ext) { return out }
  78. out.requests = 1
  79. let r = fetch(tmdbImageBase + '/w342' + posterPath, { headers = { 'user-agent' = 'tracker.worldapi.org (tmdb sync)' } timeoutMs = 20000 })
  80. if (r == null || r.status != 200 || r.body == null || r.body.length == 0) {
  81. out.failed = 'poster ' + posterPath + ': ' + (r == null ? 'no answer' : 'HTTP ' + r.status)
  82. return out
  83. }
  84. mkDir(postersDir)
  85. writeFile(file, toBytes(r.body), 420)
  86. out.saved = 1
  87. out.image = ext
  88. out.tmdbPoster = posterPath
  89. return out
  90. }
  91. // ---- one show -----------------------------------------------------------------------------------
  92. static newId = () => { return randomBytes(8, 'hex') }
  93. // which TMDB seasons to sync: every numbered one with episodes; 0 only when the show has a season 0 already
  94. static wantedSeasonNumbers = (details, localByNumber) => {
  95. let out = []
  96. if (details.seasons == null) { return out }
  97. for (s of details.seasons) {
  98. let n = s.season_number
  99. if (n != null && (n > 0 || localByNumber['' + n] != null) && (s.episode_count == null || s.episode_count > 0 || localByNumber['' + n] != null)) { out.push(n) }
  100. }
  101. return out
  102. }
  103. // the seasons in batches of 20 (TMDB's append_to_response limit)
  104. static batchesOf = (list, size) => {
  105. let out = []
  106. let cur = []
  107. for (x of list) {
  108. cur.push(x)
  109. if (cur.length == size) { out.push(cur) cur = [] }
  110. }
  111. if (cur.length > 0) { out.push(cur) }
  112. return out
  113. }
  114. // fields of an episode/season from TMDB that differ from the stored record (null/empty never overwrites)
  115. static changedText = (stored, fresh) => { return fresh != null && fresh != stored }
  116. // Sync ONE show (by our id). Answers counts: { requests, newSeasons, newEpisodes, updatedEpisodes,
  117. // updatedSeasons, posters, errors, error }. Never throws for a TMDB failure — it is counted and the show left as is.
  118. static syncShow = (showId) => {
  119. let res = { requests = 0 newSeasons = 0 newEpisodes = 0 updatedEpisodes = 0 updatedSeasons = 0 posters = 0 errors = 0 error = '' }
  120. let show = showsTable.fetch(showId)
  121. if (show == null) { res.errors = 1 res.error = 'no show ' + showId return res }
  122. if (show.tmdbId == null) { res.errors = 1 res.error = show.title + ': no tmdbId' return res }
  123. res.requests = 1
  124. let details = tmdbGet('/tv/' + show.tmdbId)
  125. if (details.failed != null) { res.errors = 1 res.error = show.title + ': ' + details.failed return res }
  126. // what we have: seasons by number, episodes by tmdbId and by S/E number
  127. let seasonsByNumber = {}
  128. let epByTmdb = {}
  129. let epByNumber = {}
  130. for (sid of show.seasons) {
  131. let s = seasonsTable.fetch(sid)
  132. if (s != null) {
  133. seasonsByNumber['' + s.seasonNumber] = s
  134. for (eid of s.episodes) {
  135. let e = episodesTable.fetch(eid)
  136. if (e != null) {
  137. if (e.tmdbId != null) { epByTmdb['' + e.tmdbId] = e }
  138. epByNumber[e.seasonNumber + 'x' + e.episodeNumber] = e
  139. }
  140. }
  141. }
  142. }
  143. // plain locals for the record literals below: inside a literal, an entry named `show`/`season` would shadow
  144. // the variable for every later entry (SPEC "A literal's entries are evaluated in order")
  145. let showKey = show.id
  146. let showTmdb = show.tmdbId
  147. let showSeasons = []
  148. for (sid of show.seasons) { showSeasons.push(sid) }
  149. let showChanged = false
  150. for (batch of batchesOf(wantedSeasonNumbers(details, seasonsByNumber), 20)) {
  151. let names = []
  152. for (n of batch) { names.push('season/' + n) }
  153. res.requests = res.requests + 1
  154. let withSeasons = tmdbGet('/tv/' + show.tmdbId + '?append_to_response=' + names.join(','))
  155. if (withSeasons.failed != null) {
  156. res.errors = res.errors + 1
  157. res.error = show.title + ': ' + withSeasons.failed
  158. } else {
  159. for (n of batch) {
  160. let ts = withSeasons['season/' + n]
  161. if (ts != null && ts.episodes != null) {
  162. let season = seasonsByNumber['' + n]
  163. let seasonIsNew = season == null
  164. let seasonChanged = false
  165. if (seasonIsNew) {
  166. season = { id = newId() seasonNumber = n title = textOr(ts.name) != null ? ts.name : 'Season ' + n
  167. release = textOr(ts.air_date) summary = textOr(ts.overview) != null ? ts.overview : ''
  168. show = showKey episodes = [] oldId = 'tmdb-season:' + showTmdb + ':' + n }
  169. } else {
  170. if (changedText(season.title, textOr(ts.name))) { season.title = ts.name seasonChanged = true }
  171. if (changedText(season.summary, textOr(ts.overview))) { season.summary = ts.overview seasonChanged = true }
  172. if (changedText(season.release, textOr(ts.air_date))) { season.release = ts.air_date seasonChanged = true }
  173. }
  174. let seasonKey = season.id
  175. let seasonEpisodes = []
  176. for (eid of season.episodes) { seasonEpisodes.push(eid) }
  177. for (te of ts.episodes) {
  178. if (te.id != null && te.episode_number != null) {
  179. let e = epByTmdb['' + te.id]
  180. if (e == null) {
  181. let byNum = epByNumber[n + 'x' + te.episode_number]
  182. if (byNum != null && byNum.tmdbId == null) { e = byNum }
  183. }
  184. if (e == null) {
  185. let fresh = { id = newId() episodeNumber = te.episode_number seasonNumber = n
  186. title = textOr(te.name) summary = textOr(te.overview) != null ? te.overview : '' release = textOr(te.air_date)
  187. show = showKey season = seasonKey tmdbId = te.id oldId = 'tmdb-episode:' + te.id }
  188. let putId = episodesTable.put(fresh)
  189. if (putId == null) {
  190. res.errors = res.errors + 1
  191. res.error = show.title + ': episode ' + te.id + ' not stored: ' + episodesTable.lastError()
  192. } else {
  193. seasonEpisodes.push(fresh.id)
  194. epByTmdb['' + te.id] = fresh
  195. res.newEpisodes = res.newEpisodes + 1
  196. seasonChanged = true
  197. }
  198. } else {
  199. let changed = false
  200. if (e.tmdbId == null) { e.tmdbId = te.id changed = true }
  201. if (changedText(e.title, textOr(te.name))) { e.title = te.name changed = true }
  202. if (changedText(e.summary, textOr(te.overview))) { e.summary = te.overview changed = true }
  203. if (changedText(e.release, textOr(te.air_date))) { e.release = te.air_date changed = true }
  204. if (changed) {
  205. if (episodesTable.update(e.id, e) == null) {
  206. res.errors = res.errors + 1
  207. res.error = show.title + ': episode ' + e.id + ' not updated: ' + episodesTable.lastError()
  208. } else {
  209. epByTmdb['' + e.tmdbId] = e
  210. res.updatedEpisodes = res.updatedEpisodes + 1
  211. }
  212. }
  213. }
  214. }
  215. }
  216. season.episodes = seasonEpisodes
  217. if (seasonIsNew) {
  218. if (seasonsTable.put(season) == null) {
  219. res.errors = res.errors + 1
  220. res.error = show.title + ': season ' + n + ' not stored: ' + seasonsTable.lastError()
  221. } else {
  222. seasonsByNumber['' + n] = season
  223. showSeasons.push(season.id)
  224. showChanged = true
  225. res.newSeasons = res.newSeasons + 1
  226. }
  227. } else if (seasonChanged) {
  228. if (seasonsTable.update(season.id, season) == null) {
  229. res.errors = res.errors + 1
  230. res.error = show.title + ': season ' + season.id + ' not updated: ' + seasonsTable.lastError()
  231. } else {
  232. res.updatedSeasons = res.updatedSeasons + 1
  233. }
  234. }
  235. }
  236. }
  237. }
  238. }
  239. // the show itself: status, counts, TMDB's overview, the poster, when it was synced
  240. let poster = syncPoster(show, details.poster_path)
  241. res.requests = res.requests + poster.requests
  242. res.posters = poster.saved
  243. if (poster.failed != null) { res.errors = res.errors + 1 res.error = show.title + ': ' + poster.failed }
  244. show.seasons = showSeasons
  245. if (textOr(details.status) != null) { show.status = details.status }
  246. if (details.number_of_seasons != null) { show.seasonsCount = details.number_of_seasons }
  247. if (details.number_of_episodes != null) { show.episodesCount = details.number_of_episodes }
  248. if (textOr(details.overview) != null) { show.tmdbSummary = details.overview }
  249. show.image = poster.image
  250. show.tmdbPoster = poster.tmdbPoster
  251. show.tmdbSync = now()
  252. if (showsTable.update(show.id, show) == null) { res.errors = res.errors + 1 res.error = show.title + ': show not updated: ' + showsTable.lastError() }
  253. return res
  254. }
  255. // flush the three tables' index/meta state to disk (after a run, and every few shows)
  256. static persistSync = () => {
  257. showsTable.persist()
  258. seasonsTable.persist()
  259. episodesTable.persist()
  260. return true
  261. }
  262. // totals of a run: the per-show counts added up
  263. static emptyTotals = () => { return { shows = 0 requests = 0 newSeasons = 0 newEpisodes = 0 updatedEpisodes = 0 updatedSeasons = 0 posters = 0 errors = 0 } }
  264. static addTotals = (t, r) => {
  265. let o = t
  266. o.shows = o.shows + 1
  267. o.requests = o.requests + r.requests
  268. o.newSeasons = o.newSeasons + r.newSeasons
  269. o.newEpisodes = o.newEpisodes + r.newEpisodes
  270. o.updatedEpisodes = o.updatedEpisodes + r.updatedEpisodes
  271. o.updatedSeasons = o.updatedSeasons + r.updatedSeasons
  272. o.posters = o.posters + r.posters
  273. o.errors = o.errors + r.errors
  274. return o
  275. }
  276. static totalsLine = (t) => {
  277. return 'shows=' + t.shows + ' newSeasons=' + t.newSeasons + ' newEpisodes=' + t.newEpisodes + ' updatedEpisodes=' + t.updatedEpisodes
  278. + ' updatedSeasons=' + t.updatedSeasons + ' posters=' + t.posters + ' requests=' + t.requests + ' errors=' + t.errors
  279. }

Branches

Latest commits

  • 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
  • 91c9fc8ctracker#5: /unwatched — unwatched released episodes of followed shows, newest firstmre
  • a97c0295tracker#4: show page /shows/:slug (header, seasons, episodes, watch checks) + tools/relink-episode-seasons.hlmre
  • 05f407c5tracker: no border on any button except inverted ones (Log out, ident status and identities too); header brand weight 100mre
  • 17375427tracker#2: tools/migrate.hl + tools/verify.hl — old MongoDB data into mpackdb with new idsmre
  • 31aac936tracker#3: no border on the header and on filled buttons; inverted buttons keep theirsmre
  • 2ad9d29ctracker#1: login exactly like calendar (identity selector in the header, empty homepage)mre
  • 3691e176tracker#1: empty tracker with the ident login (state of 2026-09-27)mre