gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit2fa9d9972fa9d997tracker#9: TMDB sync (followed shows: seasons, episodes, posters), tools/sync-tmdb.hl + daily run 04:00 UTC, fake TMDB in gatemre2fa9d997/README.md

28.7 KB

  1. # tracker.worldapi.org
  2. The new tracker: a Hybriel app that will track the TV shows (and later movies) the creator
  3. follows and watches. **`CONCEPT.md` (the creator's) is the source of truth** — read it first;
  4. nothing is built that it does not describe. Built step by step, one ticket per step
  5. (tracker.worldapi.org#1, #2, …).
  6. **Built so far — step 1 (ticket #1)**: the shell. The header carries the ident login (an
  7. identity selector, like calendar/gitoria) and sign-out; the homepage itself stays empty. No
  8. design carried over from the old app.
  9. **Step 2 (ticket #2)**: the old tracker's MongoDB export is now in `storage/mpackdb/` (new
  10. mpackdb ids, every reference re-pointed) — see "Data" and `STATUS.md`. Nothing is shown in the
  11. UI yet; the homepage still renders empty. That is a later step, from the creator.
  12. **Step 4 (ticket #4)**: the show page, `/shows/:slug` (`components/show.hl`) — poster, title,
  13. genre pills, plot, cast (as described in "What it does (step 4)" below), every season with its
  14. episodes, and the watch check (episode and season, season bulk-toggles its episodes).
  15. **Step 5 (ticket #5)**: `/unwatched` (`components/unwatched.hl`) — every already-released,
  16. unwatched episode of a show the signed-in user follows, newest release first (see "What it does
  17. (step 5)" below).
  18. **Step 6 (ticket #6)**: `/schedule` (`components/schedule.hl`) — every not-yet-released episode
  19. of a show the signed-in user follows, soonest first, no check icons (see "What it does (step 6)"
  20. below).
  21. **Step 7 (ticket #7)**: `/my/shows` (`components/myshows.hl`) — every show the signed-in user
  22. follows, newest follow first: small poster, title, last watched episode (`S08E35`) (see "What it
  23. does (step 7)" below). The homepage itself is still empty.
  24. **Step 8 (ticket #8)**: the personal lists all live under `/my/`: `/my/shows`, `/my/unwatched`,
  25. `/my/schedule` (old `/unwatched`, `/schedule` answer 301). Episodes everywhere as `S01E01`, show
  26. titles in lists with the year (`Doctor Who (2005)`), "1 episode" singular, and every `/my/` page
  27. under 1 s full load on the real data (see "What it does (step 8)" below).
  28. **Step 9 (ticket #9)**: the TMDB sync — every show someone follows gets its new seasons/episodes
  29. (existing ones updated: title, summary, release) and its poster from TMDB, once a day inside the app
  30. (04:00 UTC) and on demand with `tools/sync-tmdb.hl` (see "What it does (step 9)" below).
  31. Written in **Hybriel** on **hl:web**, same stack and conventions as `ident.worldapi.org`
  32. (vendored plugins/binary copied from there, see "Vendored Hybriel").
  33. ## Run (dev, Loreana)
  34. ```bash
  35. cd /media/STORAGE/projects/tracker.worldapi.org
  36. TRACKER_PORT=8700 TRACKER_SYNC=0 setsid nohup ./bin/hybriel project.hl > server.log 2>&1 < /dev/null & echo $! > server.pid
  37. # TRACKER_SYNC=0: the runtime loads .env (real TMDB token) by itself — without it a dev server syncs from TMDB at 04:00 UTC
  38. # stop: kill $(cat server.pid)
  39. ```
  40. * Config (env; the real environment outranks nothing here — there is no `.env` reader in
  41. project.hl, unlike ident's SMTP settings — set these in the shell or `docker-compose.yml`):
  42. | Variable | Default | |
  43. |---|---|---|
  44. | `TRACKER_PORT` | 45008 | |
  45. | `TRACKER_URL` | `http://127.0.0.1:<port>` | this app's own origin — the ident login button's `return=` is built from it |
  46. | `IDENT_URL` | `https://ident.worldapi.org` | |
  47. | `TRACKER_KEY` / `TRACKER_SECRET` | `''` | this app's API key + secret, from ident's `/apps` (register once, origin = `TRACKER_URL`) — **not set yet on Byrodin**: the first deploy (architect) registers the app in the live ident and puts these in `.env` next to `docker-compose.yml`, exactly like ident's own `.env` holds its SMTP settings |
  48. | `TRACKER_WATCH` | on | `0` = no dev watcher (the container) |
  49. | `TRACKER_STORAGE` | `./storage/mpackdb` | the `usersTable` directory |
  50. | `TRACKER_SESSIONS` | `./.sessions` (hl:web's own default) | this app's own session store |
  51. | `HL_HOST` (or `HOST`) | 0.0.0.0 | interface to bind; `127.0.0.1` on Byrodin |
  52. | `TMDB_READ_TOKEN` | — | TMDB v4 read token (in `.env`, never print it). Unset → the daily sync is off. **The runtime loads the `.env` beside `project.hl` by itself** (the real environment wins): every dev server on Loreana has the real token — start dev servers with `TRACKER_SYNC=0` |
  53. | `TRACKER_SYNC` | on | `0` = no daily TMDB run inside the app |
  54. | `TRACKER_SYNC_HOUR` | 4 | UTC hour of the daily run (hl:time has no time zones; the container is UTC) |
  55. | `TMDB_BASE_URL` / `TMDB_IMAGE_URL` | `https://api.themoviedb.org/3` / `https://image.tmdb.org/t/p` | the gate points both at its fake TMDB |
  56. Without `TRACKER_KEY`/`TRACKER_SECRET`, `/` still renders (signed out, the selector and "Log
  57. in with ident" show in the header); a login attempt answers `'login is not set up on this
  58. server (TRACKER_KEY / TRACKER_SECRET missing)'` instead of exchanging a code — so the app is
  59. never a dead 500 while waiting for that one-time setup.
  60. ## What it does (step 1)
  61. * **Login, copied unchanged from calendar.worldapi.org** (rejected once for a centered
  62. sign-in and no header selector — architect, 2026-09-27): ident only, no own passwords
  63. (ident's login button flow, ident.worldapi.org README "How apps use ident"), the identity
  64. selector (`<ident-selector>`, ident's `/selector.js`) sits in the header
  65. (`components/main.hl`, `userBox`), a plain "Log in with ident" link beside it when signed
  66. out. Choosing an identity in the selector fires `ident-login` (one-time code); `login.js`
  67. (an external-component bridge, allowed per the creator: "login.js is correct, as thats for
  68. externals in general") hands the code to a hidden input, whose `change` calls the server
  69. face `trackerLogin` (`users.hl` `exchangeCode`, `POST <ident>/api/exchange` — nothing else:
  70. ident does not hand over a display name yet, ident#23 done / ident#11 on hold), which makes
  71. or finds this app's own user record (`usersTable`, `storage/mpackdb/users.db`) and sets
  72. `session.user = { id }`. `/login/callback` is the same exchange as a plain redirect (the
  73. login button's return URL) for a client that has no socket yet. "Log out" in the header
  74. calls `trackerLogOut`; ident's own login is untouched. This app's own session cookie is
  75. `trackersid` (cookies ignore ports, an own name keeps it apart from ident's `identsid` on
  76. the same dev host, hybriel#10/#17).
  77. * **The empty homepage** (`/`, `components/home.hl`): no content at all, signed in or out —
  78. the header alone carries the login state. Nothing else.
  79. * **One mpackdb table**: `storage/mpackdb/users.db` (`identity` → this app's user id — no
  80. display name, no other per-user data yet). Step 2 (tracker.worldapi.org#2, open) brings the
  81. old tracker's data across.
  82. ## What it does (step 4)
  83. `/shows/:slug` (`components/show.hl`, slug = the migrated `urlSegment`), read-only data from
  84. `shows.hl`, per-user watch state from `watches.hl` (both reused as-is by `/unwatched` and
  85. `/schedule`, tickets #5/#6 — "reuse, don't copy"):
  86. * **Header**: poster on the left (`/posters/<name>`, `project.hl` `posterRoute` — served from
  87. `storage/mpackdb/posters/`; the real poster files were never in the Mongo backup, see
  88. `DECISIONS.md` "tracker concept" — a placeholder SVG shows until a later TMDB step brings
  89. real ones back), title, genre pills (blue, link to `/genre/<slug>`) and the plot summary to
  90. its right, the cast below as comma-separated red links to `/person/<slug>`.
  91. * **Seasons**, latest first, the latest expanded and every other one collapsed to start; each
  92. row: a round check icon (a disc with a check, drawn in SVG/CSS, no icon font), the season
  93. title/number, its episode count. Its episodes (number + title, and their own check icon)
  94. show once expanded (click the row to toggle).
  95. * **The watch check** (signed-in only — signed out, no check icons show at all): clicking an
  96. episode's check toggles that one watch; clicking a season's check toggles the season's own
  97. watch AND every one of its episodes at once (`watches.hl` `setSeasonWatched`). Every click is
  98. a server round trip (`emit server showToggleEpisode` / `showToggleSeason` / `showRows` for
  99. collapse/expand) that rebuilds the row list server-side and reassigns it whole — a season has
  100. at most a few dozen episodes, cheap. A **Hybriel gap found here** (filed as hybriel#115,
  101. reported after this ticket's first attempt): a plain per-instance member function that calls
  102. an hl:mpackdb-backed import comes back `null` when called from inside an `on server` face,
  103. even though the very same call works as a member's own top-level initializer — the fix is to
  104. declare such helpers `static` (the same shape calendar.worldapi.org's `soonOf` already uses).
  105. `components/show.hl`'s own helpers (`buildRows`, `genreRowsOf`, …) are `static` for exactly
  106. this reason.
  107. * **Data fix**: `tools/relink-episode-seasons.hl`, a one-off idempotent tool that links every
  108. episode whose `season` was still null in the migrated data (36,194 of them, ticket #2) to its
  109. season by matching `(show, seasonNumber)` — never touches an episode whose season is already
  110. set, never runs `update()` on the live table (hybriel#113; it rebuilds a fresh episodes table
  111. and swaps the files over, the same rule `tools/migrate.hl` follows). Not run against the live
  112. data yet — the architect runs it (stop the container first, `storage/mpackdb` is not shared
  113. across processes, hybriel#21).
  114. ## What it does (step 5)
  115. `/unwatched` (`components/unwatched.hl`): every episode of a show the signed-in user follows
  116. (`follows.hl`, new) that is already released (`release` ≤ today) and not yet watched
  117. (`watches.hl` `isWatched`), newest release first. Creator: "/unwatched lists all unwatched
  118. episodes of shows followed in release date desc order"; "check icon is a disc with a check in
  119. it" — the SAME icon/class (`watches.hl` `watchClassOf`, moved there from `components/show.hl` so
  120. both pages share one definition instead of each declaring its own) and the same
  121. `setWatched`/`isWatched` watch logic as the show page (step 4), not copied.
  122. * Each row: check icon, the show's title (linking to `/shows/:slug`), "Ep `<number>` ·
  123. `<title>`", the release date.
  124. * A row only ever shows an unwatched episode, so a click always marks it watched (never toggles
  125. back, unlike the show page's checks) — the row leaves the list. Signed out: no rows, a
  126. "Sign in to see the shows you follow." message instead.
  127. * `follows.hl` (new): read-only per-user follow data (`followedShowIds`), kept apart from
  128. `shows.hl`/`watches.hl` the same way, for `/schedule` (ticket #6) to reuse.
  129. ## What it does (step 6)
  130. `/schedule` (`components/schedule.hl`): every episode of a show the signed-in user follows
  131. (`follows.hl`) that is NOT yet released (`release` > today), soonest first. Creator: "/schedule
  132. has a similar list without check icons for the upcoming episodes of shows followed" — same row
  133. layout as `/unwatched` (show title linking to `/shows/:slug`, "Ep `<number>` · `<title>`", the
  134. release date), minus the check icon and minus any click handling: the page is read-only, so it
  135. needs no `on server` face at all. Signed out: no rows, the same "Sign in to see the shows you
  136. follow." message as `/unwatched`.
  137. ## What it does (step 7)
  138. `/my/shows` (`components/myshows.hl`): every show the signed-in user follows, ordered by the
  139. follow's `at` (epoch ms), newest first. Creator: "trackers /my/shows that just ists the shows in
  140. desc order i followed them, small poster, title, last episode like s08e35". Read-only, no `on
  141. server` face (like `/schedule`).
  142. * Each row: a small poster (2.5rem wide, the same `/posters/<name>` route + placeholder as the show
  143. page; a show with no poster name at all gets `/posters/none`, i.e. the route's placeholder), the
  144. title linking to `/shows/<slug>`, and the **last watched episode** as `SxxEyy` (zero-padded to
  145. two digits) = the HIGHEST season/episode number the user has an episode watch for (architect's
  146. reading, in the ticket) — not the most recently watched one; season watches alone don't count.
  147. Nothing watched → no episode element at all.
  148. * Signed out: no rows, the same "Sign in to see the shows you follow." as `/unwatched`.
  149. * Cost: touches only the user's follows + episode watches (one `episodeById` fetch per watch),
  150. never all episodes of a followed show — 128 rows in ~0.2 s on a copy of the real data (STATUS.md).
  151. * Reuse: `follows.hl` `followsOfUser` (new — the whole follow record, `followedShowIds` now
  152. builds on it), `shows.hl` `episodeById` (new, one line), `showById`, `posterName`, `watches.hl`
  153. `watchesOfUser`.
  154. ## What it does (step 8)
  155. * Routes (`project.hl`): `/my/unwatched`, `/my/schedule`; the old `/unwatched`, `/schedule` are
  156. function routes answering **301** to them (`movedTo`, no query carried — the pages take none).
  157. * `shows.hl` (shared by all lists): `episodeCode(s, e)` → `S01E01` (`/my/shows`, `/my/unwatched`,
  158. `/my/schedule` rows: `S01E02 · <title>`), `titleWithYear(show)` → `Doctor Who (2005)` (`year`,
  159. else the first 4 chars of `release`, else the bare title — 197 shows have no `year`), `pad2`,
  160. `posterUrlOf(show)`. The show page's own `h1` stays without the year (it is not a list).
  161. * Show page: season count `1 episode` / `N episodes`.
  162. * **Speed** (the creator's real data: 128 follows, 11,692 watches, 831 unwatched rows):
  163. * `/unwatched` was ~15 s: `isWatched` scanned all 11,692 watches per episode. Now
  164. `watches.hl` `watchedSet(rows)` builds `'<type>:<id>' → true` ONCE; `/my/unwatched` and the
  165. show page's `buildRows` look up that map. Now ~0.6 s server render (the rest is fetching
  166. every episode of 128 shows; the insertion sort is ~50 ms).
  167. * `/my/shows` "25 s full load while data takes 0.2 s": (1) **hl:web serves one request at a
  168. time** — measured: a `/my/shows` request made during a 15 s `/unwatched` render waited 13.7 s;
  169. every poster request of every visitor queues the same way. Fixing `/unwatched` removes that.
  170. (2) 128 rows = 128 different `/posters/<oldId>.jpg` URLs, all answering the same placeholder
  171. (no poster files exist yet) — 128 requests per load. `posterUrlOf` now gives rows without a
  172. poster FILE the one URL `/posters/none` → 1 request, cached.
  173. * How it was measured: see STATUS.md ticket #8 ("Real-data check").
  174. ## What it does (step 9)
  175. `tmdbsync.hl` (`syncShow`), run by the app every day and by `tools/sync-tmdb.hl`:
  176. * **Which shows**: every show ANY user follows (`follows.hl` `allFollowedShowIds`, 126 on the real data).
  177. * **Per show** (1 + ⌈seasons/20⌉ requests + the poster if new): `GET /tv/<tmdbId>` (status, counts,
  178. overview, `poster_path`, the season list), then `GET /tv/<tmdbId>?append_to_response=season/1,…` (≤ 20
  179. seasons with their episodes per request). Bearer `TMDB_READ_TOKEN`.
  180. * **Matching, never duplicating, never deleting**: episode by `tmdbId`, else (161 migrated episodes have
  181. none) by S/E number if that one has no tmdbId yet — then it gets the tmdbId; season by (show,
  182. seasonNumber) (migrated seasons have no tmdbId). Existing episodes/seasons: title, summary, release
  183. updated; an empty TMDB value never overwrites. New ones: self-assigned 16-hex ids (NOT mpackdb's own —
  184. hybriel#113), `oldId = 'tmdb-episode:<id>'` / `'tmdb-season:<showTmdb>:<n>'` (the tables' `!oldId`
  185. index), linked into `season.episodes` / `show.seasons`. Show: `status`, `seasonsCount`,
  186. `episodesCount`, `tmdbSummary`, `tmdbPoster`, `tmdbSync` (ms). Season 0 (specials) only for a show that
  187. already has one; a TMDB season with no episodes is not created.
  188. * **Posters**: TMDB `poster_path`, size w342 → `storage/mpackdb/posters/<oldId>.<ext>` (the folder the
  189. existing `/posters/:name` route already served; inside `storage/` → in the nightly backup), `show.image`
  190. set to the ext. Downloaded again only when the file is missing or TMDB's `poster_path` changed.
  191. * **Daily run inside the app** (`project.hl` `syncTick`): an hl:time `every(1)` clock; at
  192. `TRACKER_SYNC_HOUR` (UTC), once per day, it queues all followed shows and then syncs ONE show per tick.
  193. hl:web serves one request at a time and `fetch()` blocks the whole process, so a step blocks requests
  194. for its duration (real data: ~0.7 s average, Saturday Night Live 53 seasons ~1.7 s; logged as
  195. `tmdb sync: slow step …` above 1.5 s) — requests queued meanwhile are served between two ticks; a tick
  196. never runs inside a page render. After a show with n requests the next waits n × 260 ms (TMDB ≤ 40
  197. requests / 10 s). Log: `tmdb sync: daily at 4:00 UTC`, `tmdb sync: start, N followed shows`,
  198. `tmdb sync done: shows=… newSeasons=… newEpisodes=… updatedEpisodes=… updatedSeasons=… posters=…
  199. requests=… errors=… seconds=…` (`docker logs tracker.worldapi.org | grep tmdb`). Tables are persisted
  200. every 10 shows and at the end (~36 ms).
  201. * **The tool** (same sync, sequential, paced; prints one line per show + the totals). **App stopped or a
  202. COPY only** — hl:mpackdb is one process per storage (hybriel#21):
  203. ```bash
  204. set -a; . ./.env; set +a # TMDB_READ_TOKEN, never print it
  205. TRACKER_STORAGE=$PWD/.scratch/realdata/storage/mpackdb ./bin/hybriel tools/sync-tmdb.hl [--limit N]
  206. ```
  207. * Real data (copy, 2026-09-30): first run 126 shows → +20 seasons, +800 episodes, 2651 episodes
  208. updated, 125 posters (6.4 MB), 380 requests, 0 errors, 186 s; a second run: all 0, 255 requests, 90 s.
  209. Details: STATUS.md ticket #9.
  210. ## Test
  211. ```bash
  212. node tests/browser.mjs # THE GATE: this app's own server + its OWN ident (a copy of
  213. # ident's code without .env, codes to a mail sink — no live
  214. # ident, no real mail; tests/identkit.mjs, same as calendar's
  215. # gate) + a real headless Chrome: signed out (header shows the
  216. # selector + "Log in with ident", the page itself is empty) ->
  217. # sign in -> header shows "Log out", page still empty -> sign
  218. # out -> signed out again -> a reload stays signed out. Also
  219. # checks the header has no bottom border and filled buttons
  220. # (incl. the ident selector's) have none, while inverted ones
  221. # ("Log out") keep theirs.
  222. # tracker.worldapi.org#4: a fixture show (tests/seed-show.hl,
  223. # written into the gate's own storage before the server starts)
  224. # proves /shows/:slug — header renders, no checks signed out;
  225. # signed in, click an episode check (turns solid), click a
  226. # season check (itself AND its episodes turn solid, proven
  227. # after a reload — server-side, not just client state).
  228. # tracker.worldapi.org#5: the same fixture (now followed by the
  229. # test user) proves /unwatched — signed out, no rows; signed in,
  230. # only the two already-released episodes show, newest first, the
  231. # far-future one excluded; click a check, the row disappears
  232. # (proven server-side after a reload).
  233. # tracker.worldapi.org#6: the same fixture proves /schedule —
  234. # signed out, no rows; signed in, only the far-future episode
  235. # shows (the two already-released ones excluded), no check icons
  236. # at all, unaffected by any watch toggle on the other pages.
  237. # tracker.worldapi.org#7: the fixture now has a 2nd followed
  238. # show (followed later, no poster name, never watched) and
  239. # proves /my/shows — signed out, the sign-in message; signed
  240. # in, newest follow first, title links, loaded posters (the
  241. # placeholder), S01E02 after /unwatched's click, then S02E01
  242. # (highest, not most recent) after the show-page checks; the
  243. # unwatched show shows no episode.
  244. # tracker.worldapi.org#8: the lists moved to /my/unwatched and
  245. # /my/schedule (old addresses: 301, and the browser lands on
  246. # the new one), rows say S01E02 / S02E01, titles carry the
  247. # year ("Gate Test Show (2024)", "Unwatched Gate Show (2025)"
  248. # — from `release`, that show has no `year`), the show page
  249. # says "1 episode" / "2 episodes", /my/shows gives the show
  250. # with a poster FILE (the gate writes posters/sh1.png) its
  251. # own URL and the other one /posters/none.
  252. # tracker.worldapi.org#9: a FAKE TMDB (tests/faketmdb.mjs, a
  253. # node http server inside the gate, fixed JSON + a png; the
  254. # app/tool get TMDB_BASE_URL/TMDB_IMAGE_URL + a gate token —
  255. # never the real TMDB). App stopped → tools/sync-tmdb.hl: +2
  256. # seasons, +4 episodes, 3 fixture episodes matched by S/E and
  257. # updated, 2 posters, no season 0, Bearer token on every call;
  258. # a 2nd run changes nothing. Restarted: /my/schedule and
  259. # /my/unwatched show the new episodes, /my/shows + the show
  260. # page the downloaded posters (/posters/sh2.png byte-equal),
  261. # S1 keeps its watched episodes, no duplicates. Then the
  262. # app's OWN daily run (TRACKER_SYNC_HOUR = the current UTC
  263. # hour, fake TMDB slowed to 700 ms/request, one new episode):
  264. # it runs, finds the episode, and pages asked meanwhile are
  265. # answered < 2.5 s (between shows) though the run takes ≥ 3 s.
  266. # 64 checks. Own servers :8700 (this app) / :8701 (ident copy)
  267. # / :8710 (fake TMDB); own storage .scratch/gate-store; Chrome
  268. # on 8702-8709. Other ports (workers get 8720-8739):
  269. # TRACKER_GATE_PORT=8720 TRACKER_GATE_IDENT_PORT=8721 TRACKER_GATE_CHROME=8722-8726 TRACKER_GATE_TMDB_PORT=8727 node tests/browser.mjs
  270. # TRACKER_GATE_SHOTS=/tmp/x also writes home/show/my-shows/
  271. # my-unwatched/my-schedule.png + synced-*.png (after the
  272. # sync) (1280×900) — LOOK at them
  273. ps -eo pid,args | grep [h]l-browser-tier # must print nothing afterwards
  274. ```
  275. ## Deploy (Byrodin)
  276. Target: `/CONTAINERS/projects/tracker.worldapi.org` on Byrodin, container
  277. `tracker.worldapi.org` (`docker-compose.yml`: debian:12-slim, host network,
  278. `HL_HOST=127.0.0.1`, `TRACKER_PORT=45008`, `TRACKER_WATCH=0`, the folder mounted at
  279. `/home/tracker`, `./bin/hybriel project.hl`), public https://tracker.worldapi.org/ via
  280. nginx (TLS ends there; no baseUrl/tls in the app, like ident/notes).
  281. * **First deploy: done by the architect** (folder, nginx vhost with WebSocket Upgrade
  282. headers, cert, DNS, **and registering this app in the live ident** — `/apps` → name +
  283. origin `https://tracker.worldapi.org` → the API key + secret go into `.env` next to
  284. `docker-compose.yml`, `TRACKER_KEY=… TRACKER_SECRET=…`).
  285. * **Later: `./deploy.sh`** on Loreana, in this folder: runs the gate (refuses on a failure;
  286. `--skip-tests` skips it LOUDLY), backs up `storage/`/`.sessions/`/`.env` (what exists) to
  287. Loreana's `/media/SLOW1TB2/deploy-backups/<app>/` (newest 5 kept; an empty/failed backup stops
  288. the deploy), then rsyncs the code to
  289. `[email protected]:/CONTAINERS/projects/tracker.worldapi.org` (never `storage/`,
  290. `.sessions/`, `.env`, `.scratch/`, `server.*`, logs — the preview is checked for them; no
  291. `--delete`), `docker compose up -d && docker compose restart` over `ssh -F /dev/null`,
  292. then waits for https://tracker.worldapi.org/ to answer 200.
  293. * `./deploy.sh --dry-run` = the gate + `rsync -n` + the commands it would run (no restart, no
  294. URL check). `--target DIR|HOST:DIR` and `--url URL` point it elsewhere (tested against a
  295. local directory, see STATUS.md).
  296. ## Data
  297. `storage/mpackdb/`: `users.db` (`identity` → this app's own user id, `users.hl`) plus, since
  298. step 2, the old tracker's data — `genres.db` (27), `persons.db` (15157), `shows.db` (9453),
  299. `seasons.db` (6430), `episodes.db` (231584), `follows.db` (128), `watches.db` (11692), all with
  300. fresh mpackdb ids and every reference re-pointed (`tools/migrate.hl`); the one old user is this
  301. app's user for ident short id `az5b2`. Since step 9 the TMDB sync adds seasons/episodes and
  302. `storage/mpackdb/posters/<oldId>.<ext>` (w342, ~50 KB each), and sets `tmdbSync`/`tmdbPoster` on
  303. shows. Known in the migrated data (not caused by the sync): 12 duplicate episode tmdbIds and 1
  304. episode whose `show`/`season` disagrees among the followed shows. Counts and the referential-integrity proof:
  305. `STATUS.md` "ticket #2". Nothing of this is shown in the UI yet.
  306. ## Files
  307. | File | |
  308. |---|---|
  309. | `CONCEPT.md` | the creator's concept — do not edit |
  310. | `project.hl` | manifest: the daily TMDB run (`syncTick`, #9), routes (`/login/callback`, `/login/failed`, `/login.js`, `/posters/:name`, `/shows/:slug`, `/my/shows`, `/my/unwatched`, `/my/schedule`, `/unwatched` + `/schedule` → 301, `/` component Home), the app's own session cookie |
  311. | `users.hl` | the ident exchange + the `usersTable`, copied from calendar.worldapi.org's `users.hl` |
  312. | `login.js` | bridge between `<ident-selector>`'s `ident-login` event and the shell, copied verbatim from calendar.worldapi.org |
  313. | `components/home.hl` | `/`: the empty homepage — no content, signed in or out |
  314. | `components/main.hl` | the shell (header: brand, `<ident-selector>`, log in / log out) |
  315. | `components/loginfailed.hl` | `/login/failed`: why a login didn't work |
  316. | `components/show.hl` | `/shows/:slug`: the show page (header, seasons, episodes, watch check) |
  317. | `components/unwatched.hl` | `/my/unwatched`: unwatched, already-released episodes of followed shows, newest first |
  318. | `components/schedule.hl` | `/my/schedule`: not-yet-released episodes of followed shows, soonest first, no checks |
  319. | `components/myshows.hl` | `/my/shows`: followed shows, newest follow first — small poster, title, last watched `SxxEyy` |
  320. | `shows.hl` | read-only data access (shows/seasons/episodes/genres/persons), plus the list labels `episodeCode`/`titleWithYear`/`posterUrlOf` (#8), reused by the `/my/` pages |
  321. | `follows.hl` | read-only per-user follow data (`followsOfUser`, `followedShowIds`), reused by `/unwatched`/`/schedule`/`/my/shows`; `allFollowedShowIds` (#9: every show anyone follows, for the sync) |
  322. | `tmdbsync.hl` | the TMDB sync of one show (`syncShow`: seasons, episodes, poster) + run totals — used by `project.hl`'s daily run and `tools/sync-tmdb.hl` (#9) |
  323. | `watches.hl` | per-user watch state (episode/season checks, the shared check-icon class `watchClassOf`, `watchedSet` = the user's watches as a map, built once per page), reused by `/unwatched`/`/schedule`/`/my/shows` |
  324. | `styles.hl` | all CSS (imports the tokens from `shared/tokens.hl`; accent green `#4ec9b0`) |
  325. | `shared/tokens.hl` | the WorldAPI tokens, vendored verbatim from `ident.worldapi.org/shared/tokens.hl` |
  326. | `tests/browser.mjs` | the gate (above) |
  327. | `tests/seed-show.hl` | writes the gate's fixture show (two seasons, three episodes) and, since ticket #5, a follow of it for the test user (#7: plus a 2nd, later-followed, never-watched show), before the server starts |
  328. | `tests/identkit.mjs` | starts a throwaway ident copy for the gate, copied from calendar.worldapi.org's `tests/` |
  329. | `tests/faketmdb.mjs` | the gate's fake TMDB (#9): details, `append_to_response=season/N`, w342 png; records every request |
  330. | `tests/cdp.mjs`, `tests/ports.mjs` | the CDP browser driver, copied from `ident.worldapi.org/tests/` |
  331. | `docker-compose.yml`, `Dockerfile`, `deploy.sh` | Byrodin container; the deploy from Loreana (section "Deploy") |
  332. | `tools/migrate.hl` | one-off: old MongoDB export → `storage/mpackdb/` (step 2, idempotent, see `STATUS.md`) |
  333. | `tools/verify.hl` | one-off: proves the migrated data against a COPY of `storage/mpackdb/` (counts, zero dangling references, one show end to end) |
  334. | `tools/sync-tmdb.hl` | the TMDB sync from the command line (#9) — app stopped or a copy; `--limit N`; prints counts |
  335. | `tools/relink-episode-seasons.hl` | one-off: links episodes with no `season` (step 2 leftovers) by `(show, seasonNumber)` — not run against the live data yet, see "What it does (step 4)" |
  336. ## Vendored Hybriel
  337. `bin/hybriel` + `plugins/` copied verbatim from `ident.worldapi.org` (2026-09-27), sha256
  338. `9e5e95b33680eb68a016e9e48a76c9f92193fd2ffdbdf437c1aab1bda2731a0d` — hybriel master
  339. 837fe120 (ident's mission 036), the newest vendored+gated build available locally. No local
  340. patch. Re-vendor the same way: copy `bin/hybriel` + `plugins/` from a current worldapi app,
  341. run the gate.

Branches

Latest commits

  • 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