gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitd924f398d924f398tracker mission 028: comments name the new files (sync.hl, dailysync.hl, backfill.hl, credits.hl, jobs.hl, images.hl …); tools/ref-params.py + tools/lambda-audit.py also scan lib/ (they globbed the root only), lambda-audit counts a plain `x = p` alias like `let x = p`mred924f398/README.md

131.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. **Step 10 (ticket #10)**: an installable app (PWA) with its own icon — a TV with a check mark, tracker green
  32. on dark; also the favicon. The shell (`/`) opens without network and says it is offline (see "What it does
  33. (step 10)" below).
  34. **Step 12 (ticket #12)**: the show page's header has small link icons to the show on TMDB, IMDb, TheTVDB and
  35. TVmaze (a movie: TMDB + IMDb), only for the ids the show has, each in a new tab; the TMDB sync fills missing
  36. ids (TMDB `external_ids`, TVmaze lookup) and never overwrites one (see "What it does (step 12)" below).
  37. **Step 13 (ticket #13)**: the homepage has content — signed in, four big icons (Unwatched, Schedule, Shows,
  38. Movies → the `/my/` pages); for everyone a short text about the site, the 10 latest released movies and the 10
  39. series with the newest released episode (headings link to `/movies` and `/shows`: everything, 24 per page). Plus
  40. `/my/movies` (the movies you follow); `/my/shows` is series only now (see "What it does (step 13)" below).
  41. **Step 15 (ticket #15)**: the daily sync also asks TVmaze for every series and adds what TVmaze has but TMDB not
  42. yet — new episodes/seasons, missing titles and air dates; TMDB values are never overwritten (see "What it does
  43. (step 15)" below).
  44. **Step 14 (ticket #14)**: the search — a magnifier in the header opens `/search`; typing searches OUR shows, movies and
  45. people at once (in-memory index), "Fetch from web" lists TMDB titles we don't have yet, "Add" imports one like the daily
  46. sync and opens its page (signed out: the sign-in modal) (see "What it does (ticket #14)" below).
  47. **Mission 010 (old 054)**: adult titles never appear in the public lists (homepage rows, `/movies`, `/shows`) or the search (ours and
  48. "Fetch from web") — only titles TMDB says are NOT adult are listed, unknown counts as hidden. A followed title stays on the
  49. user's `/my/` pages and its page. A background job in the app (the "adult backfill") asks TMDB once per title for the
  50. adult flag, the poster and the external ids (see "What it does (mission 010 (old 054))" below).
  51. **Step 16 (ticket #16)**: person pages — `/person/<slug>` (the show page's cast and the search's people link there): name,
  52. photo, born/died, a short bio, the filmography (poster, title, year, role, newest first). A person we don't have complete yet is
  53. fetched from TMDB on the first visit (person + all credits, every missing title added), the page fills in without a reload
  54. (see "What it does (ticket #16)" below).
  55. **Mission 016 (old 060) (ticket #27)**: every movie, series and person has a 5-character short id (`shortId`, like `a9s9a`) shown
  56. under its poster / photo; the 702 titles the old tracker numbered keep theirs (`100jh`…); `/<shortId>` answers 301 to the page
  57. (see "What it does (mission 016 (old 060))" below).
  58. **Ticket #26**: titles a person page adds from a filmography (#16: minimal records) are completed — opening one shows skeletons and
  59. fills in poster, links, cast, seasons/episodes without a reload; a background job (the "details repair") completes the rest (see
  60. "What it does (ticket #26)" below).
  61. **Ticket #28**: a title page shows its WHOLE cast (a series: every season's, main cast first by episode count, then "Guest stars")
  62. with the characters, collapsed after 20 with "Show all (N)", and its key crew — Created by, Directed by, Written by, Screenplay,
  63. Story, Music — every name linked to its person page. Stored by the daily sync, the search import and the completion of #26 from the
  64. details request they make anyway; every other title gets them once from a background job (the "credits job") (see "What it does
  65. (ticket #28)" below).
  66. **Ticket #18**: the daily sync asks TMDB's and TVmaze's CHANGE LISTS first (what changed since the last run) and syncs only the
  67. titles of ours that changed — followed or not, so every title stays current; it refreshes the title's own record too (title,
  68. TMDB text, tagline, status, genres …). `summary` is the creator's own text and never written by the sync: the page shows
  69. `summary`, else TMDB's `tmdbSummary`, else TVmaze's `tvmazeSummary` (see "What it does (ticket #18)" below).
  70. **Mission 022 (old 068) (tickets #20, #21)**: every page heading says its type first, in the type's colour, then the name in white
  71. ("Series | Reacher", "Movie | …", "Show | …", "Genre | Action", "Person | …"). TV titles are split into **Series** (scripted,
  72. mini-series, documentary) under `/series` and **Shows** (reality, talk, news, video) under `/shows`; movies live at
  73. `/movies/<slug>`. The old `/shows/<slug>` of a series or movie answers 301. Home, search, `/my/series` + `/my/shows` follow the
  74. split. A background job asks TMDB once per TV title for its type (details: [docs/kinds.md](docs/kinds.md)).
  75. **Ticket #19**: franchises and timelines — `/franchises`, `/franchises/<slug>`, `/timelines/<slug>`, a `‹ Prequel | Timeline |
  76. Sequel ›` widget on title pages, the creator's editor, and a background seed from TMDB movie collections (see "What it does
  77. (ticket #19)" below, [docs/franchises.md](docs/franchises.md)).
  78. **Mission 023 (old 070) (2026-10-02)**: #18, #20/#21 and #19 merged into main; the sync never destroys migrated data (copied summaries are
  79. MOVED to `migratedSummary`, a replaced title/genres/homepage/tagline is kept in `migrated*`); Hybriel re-vendored to 8efba065
  80. (memory fix #126) with `&` on read-only lambda parameters (#48); `HL_GC_BYTES` stays Hybriel's default (see "Deploy" and
  81. "Vendored Hybriel" below, STATUS.md mission 023 (old 070)).
  82. **Mission 024 (old 071) (2026-10-02)**: Hybriel master 190aa11d (#127 both shapes) measured on the real copy against the live binary —
  83. NOT adopted (after the first-start jobs `/my/series` 2.4× slower, RSS not flat); the vendor stays 8efba065. The build is ready
  84. in `.scratch/w071/vendor-190aa11d/`; numbers and the open decision: STATUS.md mission 024 (old 071).
  85. Written in **Hybriel** on **hl:web**, same stack and conventions as `ident.worldapi.org`
  86. (vendored plugins/binary copied from there, see "Vendored Hybriel").
  87. ## Run (dev, Loreana)
  88. ```bash
  89. cd /media/STORAGE/projects/tracker.worldapi.org
  90. TRACKER_PORT=8700 TRACKER_SYNC=0 setsid nohup ./bin/hybriel project.hl > server.log 2>&1 < /dev/null & echo $! > server.pid
  91. # TRACKER_SYNC=0: the runtime loads .env (real TMDB token) by itself — without it a dev server syncs from TMDB at 04:00 UTC
  92. # stop: kill $(cat server.pid)
  93. ```
  94. * Config (env; the real environment outranks nothing here — there is no `.env` reader in
  95. project.hl, unlike ident's SMTP settings — set these in the shell or `docker-compose.yml`):
  96. | Variable | Default | |
  97. |---|---|---|
  98. | `TRACKER_PORT` | 45008 | |
  99. | `TRACKER_URL` | `http://127.0.0.1:<port>` | this app's own origin — the ident login button's `return=` is built from it |
  100. | `IDENT_URL` | `https://ident.worldapi.org` | |
  101. | `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 |
  102. | `TRACKER_WATCH` | on | `0` = no dev watcher (the container) |
  103. | `TRACKER_STORAGE` | `./storage/mpackdb` | the `usersTable` directory |
  104. | `TRACKER_SESSIONS` | `./.sessions` (hl:web's own default) | this app's own session store |
  105. | `HL_HOST` (or `HOST`) | 0.0.0.0 | interface to bind; `127.0.0.1` on Byrodin |
  106. | `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` |
  107. | `TRACKER_SYNC` | on | `0` = no daily TMDB run inside the app |
  108. | `TRACKER_SYNC_HOUR` | 4 | UTC hour of the daily run (hl:time has no time zones; the container is UTC) — #18: by change lists since the day in `storage/mpackdb/sync-state.txt` |
  109. | `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 |
  110. | `TVMAZE_BASE_URL` | `https://api.tvmaze.com` | #12: the TVmaze id lookup; the gate points it at its fake (`<fake>/tvmaze`) |
  111. | `TRACKER_BACKFILL` | on | mission 010 (old 054): `0` = no adult-flag backfill (off too without `TMDB_READ_TOKEN`). **A dev server with the real token runs it** — it only writes the storage it was given, but asks real TMDB |
  112. | `TRACKER_BACKFILL_LOG_EVERY` | 500 | a progress line every N titles |
  113. | `TRACKER_OLD_SHORT_IDS` | `./data/old-short-ids.json` | mission 016 (old 060): the old tracker's short ids (`oldId` → id), part of the code (deployed with it) |
  114. | `TRACKER_SHORTIDS` | on | mission 016 (old 060): `0` = no short-id backfill (the gate's resume check; ids for NEW records are still given) |
  115. | `TRACKER_SHORTID_BUDGET_MS` | 40 | mission 016 (old 060): ms of work per backfill batch (one batch per 0.1 s tick) |
  116. | `TRACKER_SHORTID_LOG_EVERY` | 5000 | mission 016 (old 060): a progress line every N records |
  117. | `TRACKER_REPAIR` | on | #26: `0` = no details repair (off too without `TMDB_READ_TOKEN`). Runs after the adult backfill is done. **A dev server with the real token runs it** (writes only its storage, asks real TMDB/TVmaze) |
  118. | `TRACKER_REPAIR_LOG_EVERY` | 100 | a progress line every N titles |
  119. | `TRACKER_REPAIR_FAST` | off | `1` = 20 ms pause between titles instead of the TMDB pacing — the gate only (fake TMDB) |
  120. | `TRACKER_CREDITS` | on | #28: `0` = no credits job (off too without `TMDB_READ_TOKEN`). Runs after the backfill and the repair are done. **A dev server with the real token runs it** (writes only its storage, asks real TMDB) |
  121. | `TRACKER_CREDITS_LOG_EVERY` | 500 | a progress line every N titles |
  122. | `TRACKER_CREDITS_FAST` | off | `1` = 20 ms pause between titles — the gate only (fake TMDB) |
  123. | `TRACKER_CREDITS_MAX_MB` | 16000 | #28: the credits job pauses (for this process; goes on after the next restart) when the app's RSS is above this — the runtime never frees fetched/decoded data |
  124. | `TRACKER_CAST_MAX` | 300 | #28: at most this many cast per title (TMDB's order) — a talk show / soap lists 3,000+ one-episode guests |
  125. | `TRACKER_KINDS` | on | mission 022 (old 068): `0` = no kind backfill (TMDB's TV type per title; off too without `TMDB_READ_TOKEN`). **A dev server with the real token runs it** (~1,600 requests on the real data) |
  126. | `TRACKER_KINDS_LOG_EVERY` | 500 | mission 022 (old 068): a progress line every N titles |
  127. | `HL_GC_BYTES` | 64 MiB (Hybriel's default — **not set** in docker-compose.yml) | mission 023 (old 070): the runtime also collects garbage after this many bytes of plugin data (#126). Smaller = less memory, slower big pages; `0` = only by count (the old binary's memory). Real copy, 200 page loads after the jobs: default → `/my/unwatched` 4.8 s, RSS 2.1 → 2.8 GB; 256 MiB → 2.2 s, 2.7 → 3.5 GB but the first-start jobs grew to 12+ GB (the old binary: 15 GB); 0 → 1.2 s, 6.2 → 8.7 GB |
  128. | `TRACKER_COLLECTIONS` | on | #19: `0` = no TMDB collection seed (off too without `TMDB_READ_TOKEN`; the main gate sets `0`). **A dev server with the real token runs it** against the storage it was given |
  129. | `TRACKER_COLLECTIONS_LOG_EVERY` / `TRACKER_COLLECTIONS_PERSIST_EVERY` | 500 / 25 | #19: a progress line / a persist every N movies |
  130. | `TRACKER_EDITORS` | `az5b2` | #19: ident ids (comma list) allowed to edit franchises and timelines (the gate sets its own) |
  131. Without `TRACKER_KEY`/`TRACKER_SECRET`, `/` still renders (signed out, the selector and "Log
  132. in with ident" show in the header); a login attempt answers `'login is not set up on this
  133. server (TRACKER_KEY / TRACKER_SECRET missing)'` instead of exchanging a code — so the app is
  134. never a dead 500 while waiting for that one-time setup.
  135. ## What it does (step 1)
  136. * **Login, copied unchanged from calendar.worldapi.org** (rejected once for a centered
  137. sign-in and no header selector — architect, 2026-09-27): ident only, no own passwords
  138. (ident's login button flow, ident.worldapi.org README "How apps use ident"), the identity
  139. selector (`<ident-selector>`, ident's `/selector.js`) sits in the header
  140. (`components/main.hl`, `userBox`), a plain "Log in with ident" link beside it when signed
  141. out. Choosing an identity in the selector fires `ident-login` (one-time code); `login.js`
  142. (an external-component bridge, allowed per the creator: "login.js is correct, as thats for
  143. externals in general") hands the code to a hidden input, whose `change` calls the server
  144. face `trackerLogin` (`users.hl` `exchangeCode`, `POST <ident>/api/exchange` — nothing else:
  145. ident does not hand over a display name yet, ident#23 done / ident#11 on hold), which makes
  146. or finds this app's own user record (`usersTable`, `storage/mpackdb/users.db`) and sets
  147. `session.user = { id }`. `/login/callback` is the same exchange as a plain redirect (the
  148. login button's return URL) for a client that has no socket yet. "Log out" in the header
  149. calls `trackerLogOut`; ident's own login is untouched. This app's own session cookie is
  150. `trackersid` (cookies ignore ports, an own name keeps it apart from ident's `identsid` on
  151. the same dev host, hybriel#10/#17).
  152. * **The empty homepage** (until #13 — see "What it does (step 13)") (`/`, `components/home.hl`): no content at all, signed in or out —
  153. the header alone carries the login state. Nothing else.
  154. * **One mpackdb table**: `storage/mpackdb/users.db` (`identity` → this app's user id — no
  155. display name, no other per-user data yet). Step 2 (tracker.worldapi.org#2, open) brings the
  156. old tracker's data across.
  157. ## What it does (step 4)
  158. `/shows/:slug` (`components/show.hl`, slug = the migrated `urlSegment`), read-only data from
  159. `shows.hl`, per-user watch state from `watches.hl` (both reused as-is by `/unwatched` and
  160. `/schedule`, tickets #5/#6 — "reuse, don't copy"):
  161. * **Header**: poster on the left (`/posters/<name>`, `project.hl` `posterRoute` — served from
  162. `storage/mpackdb/posters/`; the real poster files were never in the Mongo backup, see
  163. `DECISIONS.md` "tracker concept" — a placeholder SVG shows until a later TMDB step brings
  164. real ones back), title, genre pills (blue, link to `/genre/<slug>`) and the plot summary to
  165. its right, the cast below as comma-separated red links to `/person/<slug>`.
  166. * **Seasons**, latest first, the latest expanded and every other one collapsed to start; each
  167. row: a round check icon (a disc with a check, drawn in SVG/CSS, no icon font), the season
  168. title/number, its episode count. Its episodes (number + title, and their own check icon)
  169. show once expanded (click the row to toggle).
  170. * **The watch check** (signed-in only — signed out, no check icons show at all): clicking an
  171. episode's check toggles that one watch; clicking a season's check toggles the season's own
  172. watch AND every one of its episodes at once (`watches.hl` `setSeasonWatched`). Every click is
  173. a server round trip (`emit server showToggleEpisode` / `showToggleSeason`; collapse/expand: see
  174. ticket #17 below) that rebuilds the row list server-side and reassigns it whole — a season has
  175. at most a few dozen episodes, cheap. A **Hybriel gap found here** (filed as hybriel#115,
  176. reported after this ticket's first attempt): a plain per-instance member function that calls
  177. an hl:mpackdb-backed import comes back `null` when called from inside an `on server` face,
  178. even though the very same call works as a member's own top-level initializer — the fix is to
  179. declare such helpers `static` (the same shape calendar.worldapi.org's `soonOf` already uses).
  180. `components/show.hl`'s own helpers (`buildRows`, `genreRowsOf`, …) are `static` for exactly
  181. this reason.
  182. * **Data fix**: `tools/relink-episode-seasons.hl`, a one-off idempotent tool that links every
  183. episode whose `season` was still null in the migrated data (36,194 of them, ticket #2) to its
  184. season by matching `(show, seasonNumber)` — never touches an episode whose season is already
  185. set, never runs `update()` on the live table (hybriel#113; it rebuilds a fresh episodes table
  186. and swaps the files over, the same rule `tools/migrate.hl` follows). Not run against the live
  187. data yet — the architect runs it (stop the container first, `storage/mpackdb` is not shared
  188. across processes, hybriel#21).
  189. ## What it does (step 5)
  190. `/unwatched` (`components/unwatched.hl`): every episode of a show the signed-in user follows
  191. (`follows.hl`, new) that is already released (`release` ≤ today) and not yet watched
  192. (`watches.hl` `isWatched`), newest release first. Creator: "/unwatched lists all unwatched
  193. episodes of shows followed in release date desc order"; "check icon is a disc with a check in
  194. it" — the SAME icon/class (`watches.hl` `watchClassOf`, moved there from `components/show.hl` so
  195. both pages share one definition instead of each declaring its own) and the same
  196. `setWatched`/`isWatched` watch logic as the show page (step 4), not copied.
  197. * Each row: check icon, the show's title (linking to `/shows/:slug`), "Ep `<number>` ·
  198. `<title>`", the release date.
  199. * A row only ever shows an unwatched episode, so a click always marks it watched (never toggles
  200. back, unlike the show page's checks) — the row leaves the list. Signed out: no rows, a
  201. "Sign in to see the shows you follow." message instead.
  202. * `follows.hl` (new): read-only per-user follow data (`followedShowIds`), kept apart from
  203. `shows.hl`/`watches.hl` the same way, for `/schedule` (ticket #6) to reuse.
  204. ## What it does (step 6)
  205. `/schedule` (`components/schedule.hl`): every episode of a show the signed-in user follows
  206. (`follows.hl`) that is NOT yet released (`release` > today), soonest first. Creator: "/schedule
  207. has a similar list without check icons for the upcoming episodes of shows followed" — same row
  208. layout as `/unwatched` (show title linking to `/shows/:slug`, "Ep `<number>` · `<title>`", the
  209. release date), minus the check icon and minus any click handling: the page is read-only, so it
  210. needs no `on server` face at all. Signed out: no rows, the same "Sign in to see the shows you
  211. follow." message as `/unwatched`.
  212. ## What it does (step 7)
  213. `/my/shows` (`components/myshows.hl`): every show the signed-in user follows, ordered by the
  214. follow's `at` (epoch ms), newest first. Creator: "trackers /my/shows that just ists the shows in
  215. desc order i followed them, small poster, title, last episode like s08e35". Read-only, no `on
  216. server` face (like `/schedule`).
  217. * Each row: a small poster (2.5rem wide, the same `/posters/<name>` route + placeholder as the show
  218. page; a show with no poster name at all gets `/posters/none`, i.e. the route's placeholder), the
  219. title linking to `/shows/<slug>`, and the **last watched episode** as `SxxEyy` (zero-padded to
  220. two digits) = the HIGHEST season/episode number the user has an episode watch for (architect's
  221. reading, in the ticket) — not the most recently watched one; season watches alone don't count.
  222. Nothing watched → no episode element at all.
  223. * Signed out: no rows, the same "Sign in to see the shows you follow." as `/unwatched`.
  224. * Cost: touches only the user's follows + episode watches (one `episodeById` fetch per watch),
  225. never all episodes of a followed show — 128 rows in ~0.2 s on a copy of the real data (STATUS.md).
  226. * Reuse: `follows.hl` `followsOfUser` (new — the whole follow record, `followedShowIds` now
  227. builds on it), `shows.hl` `episodeById` (new, one line), `showById`, `posterName`, `watches.hl`
  228. `watchesOfUser`.
  229. ## What it does (step 8)
  230. * Routes (`project.hl`): `/my/unwatched`, `/my/schedule`; the old `/unwatched`, `/schedule` are
  231. function routes answering **301** to them (`movedTo`, no query carried — the pages take none).
  232. * `shows.hl` (shared by all lists): `episodeCode(s, e)` → `S01E01` (`/my/shows`, `/my/unwatched`,
  233. `/my/schedule` rows: `S01E02 · <title>`), `titleWithYear(show)` → `Doctor Who (2005)` (`year`,
  234. else the first 4 chars of `release`, else the bare title — 197 shows have no `year`), `pad2`,
  235. `posterUrlOf(show)`. The show page's own `h1` stays without the year (it is not a list).
  236. * Show page: season count `1 episode` / `N episodes`.
  237. * **Speed** (the creator's real data: 128 follows, 11,692 watches, 831 unwatched rows):
  238. * `/unwatched` was ~15 s: `isWatched` scanned all 11,692 watches per episode. Now
  239. `watches.hl` `watchedSet(rows)` builds `'<type>:<id>' → true` ONCE; `/my/unwatched` and the
  240. show page's `buildRows` look up that map. Now ~0.6 s server render (the rest is fetching
  241. every episode of 128 shows; the insertion sort is ~50 ms).
  242. * `/my/shows` "25 s full load while data takes 0.2 s": (1) **hl:web serves one request at a
  243. time** — measured: a `/my/shows` request made during a 15 s `/unwatched` render waited 13.7 s;
  244. every poster request of every visitor queues the same way. Fixing `/unwatched` removes that.
  245. (2) 128 rows = 128 different `/posters/<oldId>.jpg` URLs, all answering the same placeholder
  246. (no poster files exist yet) — 128 requests per load. `posterUrlOf` now gives rows without a
  247. poster FILE the one URL `/posters/none` → 1 request, cached.
  248. * How it was measured: see STATUS.md ticket #8 ("Real-data check").
  249. ## What it does (step 9)
  250. `tmdbsync.hl` (`syncShow`), run by the app every day and by `tools/sync-tmdb.hl`:
  251. * **Which shows**: every show ANY user follows (`follows.hl` `allFollowedShowIds`, 126 on the real data). **Since #18 only the
  252. "full walk"** (first run, gap > 14 days, failed change list) — the normal daily run takes the changed titles (ticket #18 below).
  253. * **Per show** (1 + ⌈seasons/20⌉ requests + the poster if new): `GET /tv/<tmdbId>?append_to_response=external_ids`
  254. (status, counts, overview, `poster_path`, the season list, since #12 the external ids), then `GET /tv/<tmdbId>?append_to_response=season/1,…` (≤ 20
  255. seasons with their episodes per request). Bearer `TMDB_READ_TOKEN`.
  256. * **Matching, never duplicating, never deleting**: episode by `tmdbId`, else (161 migrated episodes have
  257. none) by S/E number if that one has no tmdbId yet — then it gets the tmdbId; season by (show,
  258. seasonNumber) (migrated seasons have no tmdbId). Existing episodes/seasons: title, summary, release
  259. updated; an empty TMDB value never overwrites. New ones: self-assigned 16-hex ids (NOT mpackdb's own —
  260. hybriel#113), `oldId = 'tmdb-episode:<id>'` / `'tmdb-season:<showTmdb>:<n>'` (the tables' `!oldId`
  261. index), linked into `season.episodes` / `show.seasons`. Show: `status`, `seasonsCount`,
  262. `episodesCount`, `tmdbSummary`, `tmdbPoster`, `tmdbSync` (ms). Season 0 (specials) only for a show that
  263. already has one; a TMDB season with no episodes is not created.
  264. * **Posters**: TMDB `poster_path`, size w342 → `storage/mpackdb/posters/<oldId>.<ext>` (the folder the
  265. existing `/posters/:name` route already served; inside `storage/` → in the nightly backup), `show.image`
  266. set to the ext. Downloaded again only when the file is missing or TMDB's `poster_path` changed.
  267. * **Daily run inside the app** (`project.hl` `syncTick`): an hl:time `every(1)` clock; at
  268. `TRACKER_SYNC_HOUR` (UTC), once per day, it queues all followed shows and then syncs ONE show per tick.
  269. hl:web serves one request at a time and `fetch()` blocks the whole process, so a step blocks requests
  270. for its duration (real data: ~0.7 s average, Saturday Night Live 53 seasons ~1.7 s; logged as
  271. `tmdb sync: slow step …` above 1.5 s) — requests queued meanwhile are served between two ticks; a tick
  272. never runs inside a page render. After a show with n requests the next waits n × 260 ms (TMDB ≤ 40
  273. requests / 10 s). Log: `tmdb sync: daily at 4:00 UTC`, `tmdb sync: start, N followed shows`,
  274. `tmdb sync done: shows=… newSeasons=… newEpisodes=… updatedEpisodes=… updatedSeasons=… posters=…
  275. requests=… errors=… seconds=…` (`docker logs tracker.worldapi.org | grep tmdb`). Tables are persisted
  276. every 10 shows and at the end (~36 ms).
  277. * **The tool** (same sync, sequential, paced; prints one line per show + the totals). **App stopped or a
  278. COPY only** — hl:mpackdb is one process per storage (hybriel#21):
  279. ```bash
  280. set -a; . ./.env; set +a # TMDB_READ_TOKEN, never print it
  281. TRACKER_STORAGE=$PWD/.scratch/realdata/storage/mpackdb ./bin/hybriel tools/sync-tmdb.hl [--limit N]
  282. ```
  283. * Real data (copy, 2026-09-30): first run 126 shows → +20 seasons, +800 episodes, 2651 episodes
  284. updated, 125 posters (6.4 MB), 380 requests, 0 errors, 186 s; a second run: all 0, 255 requests, 90 s.
  285. Details: STATUS.md ticket #9.
  286. ## What it does (step 10)
  287. The installable app, the same way calendar.worldapi.org does it (its ticket #3) — hl:web's own manifest
  288. and service worker from settings in `project.hl`, no JavaScript of ours:
  289. * `appIcons` (192 + 512 PNG, each `any` and `maskable`), `appTouchIcon` (180 PNG), `appFavicon`
  290. (`/icons/favicon.svg`), `appThemeColor` = token `darker` (the header, rgb(15, 20, 25)),
  291. `appBackgroundColor` = token `dark` (rgb(25, 30, 35)); name = `appTitle` "tracker". hl:web serves
  292. `/__hl/manifest.webmanifest` (start_url/scope `/`, display standalone) and links it, the
  293. apple-touch-icon and theme-color from every head. `/favicon.ico` is a real icon (16/32/48) for
  294. browsers that ask for it themselves. Each icon has its own `file` route in `project.hl`.
  295. * **Icons** (`icons/`): `icon.svg` is the source (512, hand-written: a TV with antennas and a check on
  296. the screen, `#4ec9b0` on rgb(25,30,35); everything inside the maskable safe zone, a circle of radius
  297. 204, so one image serves `any` and `maskable`); `favicon.svg` is the same drawing cropped tight on a
  298. rounded tile. Rendered on Loreana:
  299. ```bash
  300. rsvg-convert -w 192 -h 192 icons/icon.svg -o icons/icon-192.png
  301. rsvg-convert -w 512 -h 512 icons/icon.svg -o icons/icon-512.png
  302. rsvg-convert -w 180 -h 180 icons/icon.svg -o icons/apple-touch-icon.png
  303. for s in 16 32 48; do rsvg-convert -w $s -h $s icons/favicon.svg -o /tmp/fav-$s.png; done
  304. magick /tmp/fav-16.png /tmp/fav-32.png /tmp/fav-48.png icons/favicon.ico
  305. ```
  306. * **Offline**: `offline = [ Home ]` — the worker precaches the shell (runtime, modules, CSS, manifest,
  307. icons, favicon) and the document of `/`. Navigations are network-first; without a network `/` comes
  308. from the cache and every other page gets hl:web's own "Unavailable offline" page (503) — data pages
  309. need the network, no offline data. The shell (`components/main.hl`) shows "You are offline. Your shows
  310. and lists need the network." while `navigator.onLine` is false: hl:web gives a page no connection
  311. state and no mount hook, so an invisible `netProbe` runs an endless 1 s CSS animation (`styles.hl`
  312. `@keyframes tracker-net-tick`) and its `animationiteration` handler reads `navigator.onLine` (a write
  313. only when it changes). A server that is down while the device is online shows no note.
  314. ## What it does (step 12)
  315. * **Link icons** (`components/show.hl`, `shows.hl` `externalLinksOf`, `styles.hl` `showLinks`/`a.extlink`): under the
  316. title (phone: between the title row and the full-width poster; desktop: under the title, right of the poster) one
  317. small monochrome badge per id the show HAS — `TMDB` https://www.themoviedb.org/tv/<tmdbId> (a movie: `/movie/<id>`),
  318. `IMDb` https://www.imdb.com/title/<imdbId>/, `TVDB` https://thetvdb.com/dereferrer/series/<tvdbId>, `TVmaze`
  319. https://www.tvmaze.com/shows/<tvmzId>. A movie (`type = 'movie'`) shows only TMDB + IMDb. Each `target="_blank"
  320. rel="noopener"`. CSS only (muted grey badge, dark text, accent on hover), no copied logos. URL forms checked
  321. 2026-10-01: all four 301 to the site's slug page (TheTVDB's old `?tab=series&id=` too; `dereferrer` is its id form).
  322. * **Sync** (`tmdbsync.hl` `externalIdsFor`): the details request carries `append_to_response=external_ids` (no extra
  323. request); a MISSING `imdbId` (`tt` + digits) / `tvdbId` (number > 0) is filled from it. A show still without
  324. `tvmzId` → `GET <TVMAZE_BASE_URL>/lookup/shows?imdb=tt…`, on 404 `?thetvdb=<id>` (hl:fetch follows TVmaze's 301 to
  325. `/shows/<id>`); 550 ms pause per lookup (TVmaze 20 / 10 s) — both runners wait `pauseMsAfter(r)`. **An id the show
  326. has is never overwritten.** Totals gain `ids=` (ids filled) and `tvmazeRequests=`.
  327. * **Movies** (7814 of 9453 migrated rows are `type = 'movie'`, 1 followed: Star Trek: First Contact): before #12 the
  328. sync asked `/tv/<tmdbId>` for them — a DIFFERENT title on TMDB. Now a movie gets one `GET
  329. /movie/<tmdbId>?append_to_response=external_ids`: missing imdbId + its poster; no seasons/status/overview.
  330. * **Counting ids** (a COPY only): `TRACKER_STORAGE=$PWD/.scratch/realdata/storage/mpackdb ./bin/hybriel
  331. tools/count-external-ids.hl` → per type, all / followed: shows, tmdbId, imdbId, tvdbId, tvmzId.
  332. ## What it does (step 15)
  333. * **TVmaze merge** (`tmdbsync.hl` `mergeTvmaze`, after the TMDB step of `syncShow`, both runners): a series with a
  334. `tvmzId` → `GET <TVMAZE_BASE_URL>/shows/<tvmzId>/episodes` (1 request, 550 ms pause like the lookups). Matched by
  335. season/episode number: a missing episode is ADDED (`tmdbId = null`, `tvmzId`, `oldId 'tvmaze-episode:<id>'`; its
  336. season too if missing: `oldId 'tvmaze-season:<tvmzId>:<n>'`), an existing one gets an EMPTY title / air date
  337. filled. **A TMDB value is never overwritten.** Placeholder titles (`Episode 4`, `Épisode 4`, `Folge 4`, `TBA`,
  338. `isPlaceholderTitle`) count as empty — and TMDB's placeholder no longer overwrites a real title (else they flip
  339. daily). When TMDB lists the episode later, the TMDB step adopts the row by number (it has no tmdbId), TMDB wins.
  340. Skipped: TVmaze specials (no number), season 0 unless the show has one, summaries (TVmaze's are HTML).
  341. * **Numbering check** (`numberingAgrees`): ≥ 80 % of the episodes both know must air within a day OR share the title,
  342. and a show with episodes must share at least one S/E with TVmaze — else TVmaze is ignored for that show
  343. (`tvmazeSkipped`). Real data: The Daily Show (TVmaze seasons by year: would have added 1093 episodes), Star Trek:
  344. Prodigy / Blood & Treasure (TMDB merges double episodes → shifted), Alex Rider / Intergalactic / The Rising (other
  345. premiere dates, placeholder titles) are skipped.
  346. * Totals/tool line gain `tvmazeEpisodes= tvmazeSeasons= tvmazeFilled= tvmazeSkipped=`; `tvmazeRequests` now also
  347. counts the episode lists (≈ 125/day on real data, ~70 s more per daily run).
  348. ## What it does (ticket #14: search)
  349. Creator: "in the actionbar a search symbol that first checks our database and then has a fetch from web button that
  350. actually searches tmdb then for things we dont have yet". `components/search.hl` (page), `search.hl` (index, TMDB, import).
  351. * **Header**: a magnifier (`#searchlink`, SVG) before the ident selector → `/search` (client-side navigation).
  352. * **Address `/search/<text>`, not `/search?q=`**: hl:web gives a page its route params but no query string. A full load gets
  353. the segment already decoded by the server (an encoded `/` would split it → 404), a client-side navigation the raw one —
  354. `queryOfParam` decodes what is left, safely (malformed `%` → literal). While typing the address follows the text
  355. (`history.replaceState`; `/ ? #` become spaces). `/search?q=x` just opens the empty search page.
  356. * **Our database, as you type** (face `searchDb`, no session): `search.hl` builds an **in-memory index once at boot**
  357. (`project.hl` → `ensureIndex`, ~0.6 s on the real data, log line `search index: 9453 titles, 15157 people, 4180 keys,
  358. … ms`): every title/name → words (lower case, apostrophes dropped, punctuation = word break, Latin accents folded; a
  359. title's year is one more word); each word's first 2 and 3 letters are bucket keys. A query reads ONE bucket (its longest
  360. word's first 3 letters, 2 for a 2-letter word) and keeps entries where every query word starts one of the entry's words.
  361. Order: whole name, name starts with the text, first word does, rest; then shorter name; then series before movie, newer
  362. first. ≤ 30 titles ("The first 30 of N titles — type more…") + ≤ 10 people. Under 2 letters: "Type at least 2 letters.".
  363. People link to `/person/<slug>` (no person page yet — same as the show page's cast).
  364. * **Fetch from web** (face `searchWeb`, signed out too): ONE `GET <TMDB>/search/multi?query=…&include_adult=false&page=1`;
  365. people dropped, titles we have (same TMDB id AND kind: `series:<id>` / `movie:<id>`) left out. Poster thumbs straight
  366. from TMDB's image host (`w92`).
  367. * **Add** (face `searchImport`, session): signed out → the sign-in modal, nothing sent. Signed in → `importTitle`: one
  368. `GET /tv/<id>` (or `/movie/<id>`) for the new record (title, overview, first air/release date → year, status, genres
  369. matched to OUR genres by name, language, homepage, tagline; own 16-hex id, `oldId = tmdb-tv-<id>` / `tmdb-movie-<id>`,
  370. `urlSegment` like the migrated ones: `Pluribus`, taken → `-2`…), then **`tmdbsync.hl` `syncShow`** — seasons, episodes,
  371. poster, external ids exactly like the daily sync — `persistSync`, index updated, the public lists too (`catalog.hl`
  372. `refreshCatalogShow`, since the #13/#14 merge); then the show page opens. A title we
  373. already have answers its slug. A failure (TMDB 404 …) is shown on the page ("Could not add it: …"), the lists stay.
  374. Log: `search import: tv 225171 "Pluribus" → /shows/Pluribus (newSeasons=1 newEpisodes=9 posters=1 ids=3 requests=4 errors=0)`.
  375. * An imported title is NOT followed automatically, so the daily sync (followed shows only) does not update it later.
  376. * Hybriel traps (hybriel#121 / #122): no list is ever appended to (replaced whole); no member reads both the route param
  377. `q` and `session`; the gate checks the lists after a failed import (a session face that stays on the page).
  378. ## What it does (ticket #16: person pages)
  379. Creator: "when one clicks on an artist that has no dataset yet the old version fetched that artist and its entire bibilogrqphy
  380. so its profile was complete". `components/person.hl` (page), `people.hl` (data + the fetch).
  381. * **`/person/<slug>`** (slug = the person's `urlSegment`; `people.hl` builds a slug → id map once, ~15k people): photo (TMDB
  382. w185 → `storage/mpackdb/profiles/<oldId>.<ext>`, route `/profiles/:name` like `/posters/:name`; no file → no photo), name,
  383. "Born 4 March 1986 in …", "Died …", the bio (four lines, a click shows all), then **Filmography**: one row per title (cast and
  384. crew of the same title merged: "Hero / Director"), poster (our file, else TMDB's w92 thumb like search's web rows, else the
  385. placeholder), title, year, Movie/Series, role; newest first by the title's release (else year), undated last; each row →
  386. `/shows/<slug>`. Only PUBLIC titles are listed (merge, mission 012 (old 056): shows.hl `isPublicTitle` = `adult == false`, unknown hidden like the lists) whose credit's TMDB flag is not adult either. Unknown slug →
  387. "Not found".
  388. * **Complete** = `persons.filmographyAt` (ms) set → the page is just read, TMDB never asked again. Not complete (every migrated
  389. person; 153 carry the old tracker's credits, shown meanwhile and kept) and a `tmdbId` → **the fill**:
  390. * the page shows "Loading filmography…"; an invisible `personProbe` (endless 0.1 s CSS animation, the same trick as the
  391. shell's offline tick — hl:web has no mount hook) fires `animationiteration` → the client handler calls the face
  392. `personFill` again and again until `done` ("Loading filmography… 40 of 87 titles"), then replaces the rows and facts —
  393. no reload, works after a client-side navigation too.
  394. * step 1: `GET <TMDB>/person/<tmdbId>?append_to_response=combined_credits` (ONE request) + the photo; facts updated (empty
  395. TMDB values never overwrite: biography, birthday, deathday, place_of_birth, imdb_id, adult). Every next step: 40 titles.
  396. A title we have (search.hl `titleIdByTmdb`, TMDB id + kind) is linked; one we don't gets a MINIMAL show record (title,
  397. overview, year/release, type, tmdbId, `adult`, language, genres by TMDB id, `cast` = this person, no seasons/poster;
  398. `oldId tmdb-<tv|movie>-<id>`, a free slug — search.hl's `slugOf`), indexed for the search (`indexShow`) and put into the
  399. public lists (`catalog.hl refreshCatalogShow`). Last step: `person.shows` = the credits `{ show, character, posterPath,
  400. adult }` (+ migrated credits TMDB no longer lists), `filmographyAt`, `persist()`. Log:
  401. `person fill: Jared Harris (tmdb 15440) credits=97 added=80 requests=2 steps=4 tmdbMs=287 ms=624`.
  402. * hl:web serves one request at a time and `fetch()` blocks: the TMDB step blocks other requests for its duration
  403. (~0.25–0.65 s real), each 40-title step ~0.15 s; other requests are served between steps (real data: a 758-credit
  404. person, 20 steps, 3.8 s, other requests waited ≤ 0.61 s).
  405. * the job lives in memory (`people.hl jobs`); a restart mid-fill loses it — the next visit starts again, titles stored
  406. already are found by TMDB id (no duplicates). A TMDB failure shows "Could not load the filmography: …"; the next visit
  407. retries. No sign-in needed (no session face on this page — hybriel#121/#122 can't bite).
  408. ## What it does (ticket #26: titles from a filmography are complete)
  409. Creator: "when i go to an artist and it loads the bibliography those loaded movies and series miss most information and it stays
  410. that way when i click on them". `details.hl` (the completion), `components/show.hl` (loading state), `project.hl` (`repairTick`).
  411. * **What the filmography stored** (people.hl `addMinimalTitle`, #16): title, year/release, overview, type, tmdbId, `adult`, genres
  412. (by TMDB id), language, `cast` = only that person; NO poster file, seasons/episodes, imdb/tvdb/tvmaze ids, homepage, tagline,
  413. status, `tmdbSync`. A search import (search.hl `importTitle`) has all of those via `syncShow` — but no cast either. Real data
  414. 2026-10-01: 321 such titles (6 people filled), every open showed exactly what the filmography stored.
  415. * **Incomplete** (details.hl `isIncomplete`): `minimal = true` (set by the fill from now on) or `imported` without `tmdbSync`
  416. (the 321 existing ones, and an import whose sync failed), no `detailsAt`, no `detailsCheck`. Adult titles are never completed.
  417. * **Complete** (`completeStep`, called until `done`): step 1 = ONE `GET /movie|tv/<id>?append_to_response=external_ids,
  418. credits|aggregate_credits` (fields + cast; a movie also its ids + poster → done). A series then goes on IN STEPS with that
  419. answer (tmdbsync.hl `syncShowPart`, the daily sync's code without a second details request): one step per part of its seasons
  420. (≤ 300 episodes, ≤ 20 seasons = one `season/…` request; `TRACKER_DETAILS_PART_EPISODES`), a last step = ids, TVmaze lookup +
  421. episode merge, poster. Why: The Late Show (22 seasons, 4,252 episodes) as ONE step blocked the server 10.7 s (hl:web serves one
  422. request at a time) — in parts every other request is served between two steps. The job state lives in memory (`jobs`); a
  423. restart begins the title again, stored seasons/episodes are matched by tmdbId. Stored too: overview (summary only if ours is
  424. empty), homepage, tagline, status, genres (TMDB ids → ours), release/year/language when missing, and the **cast** (+ crew since
  425. #28 — the whole list, see "What it does (ticket #28)"; #26 kept TMDB's first 20) (billing order; a series: all seasons' cast, roles " / "-joined), adult people left out; a person we have (by tmdbId) is linked,
  426. a new one is added (people.hl `castPersonId`: name, tmdbId, free slug, `oldId tmdb-person-<id>`, this credit — their page fills
  427. the rest on the first visit; in the search at once). Then `detailsAt` (ms). TMDB 404 → `detailsCheck = 'TMDB 404'`; TMDB says
  428. adult → flag stored, `detailsCheck = 'adult'`, nothing downloaded. TVmaze/poster failures are logged (`detailsError`), the title
  429. still counts complete (the daily sync refreshes followed titles).
  430. * **On open** (`components/show.hl`): an incomplete, non-adult title renders what it has + skeletons (poster box, a cast line, 3
  431. season rows for a series; the #17 shimmer). An invisible `showProbe` (CSS animation tick, like the person page — no mount hook)
  432. calls the face `showComplete(slug)` (no session → no page re-mount) again and again — one step per call — until `done`; the
  433. last answer (title, summary, poster, genres, cast, links, collapsed, rows) is assigned on the client, no reload. Persisted at the
  434. end. Log: `details on open: "<title>" steps=… requests=… tvmazeRequests=… cast=… newSeasons=… newEpisodes=… posters=… ids=…
  435. ms=…`. A second viewer drives the same job; a reload after it: complete at once.
  436. * **The repair job** (`project.hl repairTick`, the app's clock — after the adult backfill is done, never during a daily run): at
  437. start every incomplete non-adult title (`detailsRepairQueue`), ONE `completeStep` per tick (a series: the same title until it is
  438. done), then `pauseMsAfter(step)` (260 ms per TMDB + 550 ms per
  439. TVmaze request). Resumes after a restart (the queue = what is still incomplete); passing failures (no answer, 429, 5xx) go to the
  440. back (≤ 2 more tries per start). Persisted every 10 titles. Log: `details repair: start, N incomplete titles`, every 100:
  441. `details repair: 100/401 titles, complete=… failed=… adult=… newSeasons=… newEpisodes=… posters=… cast=… requests=… tvmazeRequests=…
  442. retries=… seconds=…`, each failure, `details repair done: …`, slow steps (> 1.5 s). Titles a fill adds while the app runs are
  443. completed when opened or at the next start.
  444. * Real data (copy 2026-10-01, real TMDB): open → skeleton 0.18–0.26 s, complete 0.64–0.71 s (movie) / 1.26–1.36 s (series);
  445. The Late Show 11.4 s in 23 steps (other requests ≤ 1.3 s). Repair: 321 titles in 507 s, 0 failed, +638 seasons, +16,528
  446. episodes, 299 posters; /movies + a show page meanwhile median 0.13 s, p99 1.0 s, max 2.4 s. STATUS.md ticket #26.
  447. ## What it does (ticket #28: full cast and crew)
  448. Creator: "you show quite few people, usually there are more especially at picard, and directors and so". `details.hl` (what is
  449. stored), `components/show.hl` + `styles.hl` (the page), `project.hl` (`creditsTick`).
  450. * **Before**: the page showed `show.cast` — for 1,820 of 10,011 titles the old tracker's migrated list (Star Trek: Picard 4 names, no
  451. characters shown), nothing for the rest (Superman 1978: 0); #26 added TMDB's first 20 for filmography titles only; no crew anywhere.
  452. * **Stored** (details.hl `withCredits`, from a details answer with `credits` (movie) / `aggregate_credits` (series = every season)
  453. appended): `cast` [{ person, character, actor, episodes, guest }] — TMDB's order (a series: by episode count; roles " / "-joined),
  454. at most `TRACKER_CAST_MAX` (300; Grey's Anatomy lists 3,243, SNL 2,771), adult people left out, entries we had and TMDB does not
  455. list kept after them; `guest` = fewer episodes than `mainMinOf` (half the series' episodes, ≤ 10, ≥ 1). `crew` [{ person, name,
  456. job, episodes }] — job creator (series `created_by`) / director (Director) / writer (Writer) / screenplay / story / composer
  457. (Original Music Composer, Composer, Music); one entry per person and job; a series' by that job's episodes, a movie's in TMDB's
  458. order; ≤ 30 per job. `creditsAt` (ms). Every person linked by TMDB id; a new one gets a minimal record (people.hl `castPersonId`,
  459. #26: name, slug, search entry, this credit; their page fills on the first visit).
  460. * **Who stores them**: the completion of an incomplete title (#26 `completeStep`, same request), the daily sync and
  461. `tools/sync-tmdb.hl` (tmdbsync.hl now asks `/tv/<id>?append_to_response=external_ids,aggregate_credits` / `/movie/<id>?…,credits`
  462. — no extra request — and hands the answer back as `res.details` → `applyCredits`), the search import (`importTitle` asks ONCE with
  463. ids + credits and passes that answer to `syncShowWith` — one request less than before; the face `searchImport` stores the
  464. credits, log `search import credits: /shows/<slug> cast=… crew=… people=…`), and the **credits job** (`project.hl creditsTick`):
  465. at start (after the adult backfill and the details repair are done, never during a daily run) every public title (`adult ==
  466. false`) with a tmdbId, no `creditsAt`/`creditsCheck`, not incomplete (`creditsQueue`); ONE request per step
  467. (`/movie|tv/<id>?append_to_response=credits|aggregate_credits`), then 260 ms. Resumes after a restart (the queue = what has no
  468. credits); 404 / adult → `creditsCheck`; passing failures to the back (≤ 2 more tries). Persisted every 25 titles. Log: `credits job:
  469. start, N titles without credits`, every 500 `credits job: 500/N titles, done=… failed=… cast=… crew=… people=… requests=…
  470. retries=… slowestStepMs=… seconds=…`, failures, slow steps (> 1.5 s), `credits job done: …`. Adult titles: never. Persists only
  471. shows + people (`persistCredits`, not the 16 MB episode indexes). MEMORY: before each title it reads its RSS (/proc/self/status);
  472. above `TRACKER_CREDITS_MAX_MB` (16000) it logs `credits job: paused, RSS … MB > … — N titles left for the next start` and stops
  473. for this process (the native runtime never frees what a fetch/JSON decode took — see STATUS #28).
  474. * **`showBySlug`** (shows.hl) is a slug → id map now (one scan at the first call, a miss = one scan, remembered): the old per-call
  475. scan decoded every record — 82 ms per call, twice per show page, and it grew with the stored credits (and leaked memory).
  476. * **The page**: crew first (under the plot; a small label per job, the names comma-separated), then "Cast": `Name (Character)`, main
  477. cast, then a "Guest stars" label and the guests. The first 20 cast names show, the rest after "Show all (N)" (→ "Show less"); per
  478. crew job the first 6, the rest after "Show all crew (N)". The toggles are CLIENT-ONLY (`castClass`/`crewClass` member flip, CSS
  479. hides `.more` while `collapsed`; no face). The character + comma are the link's `::after` (`data-role`) — with a wrapper element the
  480. SSR's line breaks put a space before every comma. A completed #26 title gets cast + crew in its last `showComplete` answer.
  481. * Real data (copy 2026-10-01, real TMDB): Picard 4 → 202 cast (17 main, then guests) + 45 crew; Superman (1978) 0 → 90 cast, Donner /
  482. Mankiewicz / Puzo / John Williams; the credits job did 5,867 titles in ~51 min (0 failed; +105k people in the 2nd run), pages
  483. meanwhile median 28 ms / p99 0.44 s / max 1.1 s; show page 0.1–0.2 s full load (Picard HTML 166 kB); "Show all" 2–18 ms. Costs:
  484. people 15k → 160k, search index at boot 0.66 → 4.3 s, RSS after boot 1.6 → 4.9 GB. STATUS.md ticket #28.
  485. ## What it does (mission 018 (old 062): #26 + #28 merged into main, short ids for new people, lazy guest stars, lean watch clicks)
  486. * **Merge**: branch t26 (#26 `25a50bc`, #28 `10bb3f9`) merged into main (#27 short ids, #29). Kept both sides everywhere.
  487. * **Short ids for every new record** (shortids.hl `claimShortId`): shows were already covered (search import, filmography fill).
  488. Persons are now covered too, in people.hl `castPersonId`. That is the only place the app creates people: #26 completion,
  489. credits job, daily sync + `tools/sync-tmdb.hl`, search import, and a guest opened for the first time. A tool in `tools/`
  490. finds `data/old-short-ids.json` one folder up.
  491. * **Guest stars are no person records** (memory): a series' guest (`guest = true`, see #28) is stored on the title only.
  492. The entry is `{ person: null (or the id when we already have them), tmdbId, actor, character, profile (TMDB profile_path),
  493. episodes, guest }`. The page links it to `/person/tmdb/<tmdbId>?show=<show id>`, a function route (project.hl `guestRoute`).
  494. The first open creates the person (people.hl `guestPersonId`: name + role from that title's entry, a short id, persisted),
  495. logs `guest opened: <name> (tmdb <id>) → new person <id> from show <id>` and answers 302 → `/person/<slug>`. That page then
  496. fills from TMDB as usual. If the person exists, the route answers 302 at once. Without `?show=`, the name comes from TMDB's
  497. `/person/<id>`. An unknown id or a non-number answers 404. Guests are not in the search until opened. The main cast
  498. (≥ `mainMinOf` episodes; every movie cast) and the crew still get records.
  499. * **Watch / follow clicks** (components/show.hl): a face taking `session` makes hl:web re-mount the page and re-send every member
  500. derived from the session (SESSION SYNC). Before, that sent the whole show record (`showRow`, with 300 cast + crew entries) and
  501. the cast/crew rows. Now:
  502. * `showRow` is a small object (`pageShowOf`: title, summary, poster, short id, genres, links, follow/watch state).
  503. * Cast + crew derive from the slug (`castOfSlug`, `crewOfSlug`). An adult title (followers only) shows none; it never gets
  504. credits.
  505. * `watches.hl watchesOfUser` is kept per user until that user's next `setWatched`. It was a scan of all 11.7k watches,
  506. 3× per click.
  507. * The episode/season faces send their own `rows` only when the page opened/closed a season (`collapsedChanged`). Otherwise
  508. the session sync's rows are the answer, so they are not sent twice.
  509. * Real data: STATUS.md mission 018 (old 062).
  510. ## What it does (ticket #18: the daily sync by change lists, the show record, summaries)
  511. Creator: "it checks every show in a request to the api? cause they have an endpoint that gives a delta on bulk" and "some shows changed
  512. descriptions and titles and that did not update". `deltasync.hl` (the plan), `project.hl` `syncTick` (the run), `tmdbsync.hl`
  513. (`syncShowDelta`, the TVmaze summary), `details.hl` (`withDetailsFields`, `applySynced`), `shows.hl` `summaryOf`.
  514. * **The run** (04:00 UTC, as before; one step per clock tick, requests served between steps): `GET /tv/changes` and
  515. `GET /movie/changes?start_date=<last run day>&end_date=<today>&page=n` (100 ids per page, all pages, the Bearer token) and
  516. `GET <TVmaze>/updates/shows?since=day|week|month` — one request per step, 260 / 550 ms between them. Intersected with OUR titles via
  517. the search index (TMDB id by kind, TVmaze id; no table scan) → the queue, followed titles first. Left out: no tmdbId, incomplete
  518. (#26's repair does them), adult titles nobody follows; TVmaze's list brings only FOLLOWED series (TVmaze is merged for those only).
  519. * **Per title**: a FOLLOWED one gets the full step as before (`syncShow`: all seasons, TVmaze merge, poster, ids); one NOBODY follows
  520. the LIGHT step (`syncShowDelta`): details + ids + poster + the record, and only the seasons that can have changed — the ones we
  521. don't have, the ones where TMDB counts more episodes than we do, the newest one — no TVmaze. (Real data: Today 15,716 episodes /
  522. EastEnders 7,349 change daily; their full step blocked the server 20.8 s each and grew the process by ~5 GB.) A movie: 1 request.
  523. * **The show record** (`applySynced` → `withDetailsFields`, the same details answer, no extra request): title (series `name`, movie
  524. `title`; the slug stays), TMDB overview → `tmdbSummary`, homepage, tagline, status, genres, adult; type/release/year/language only
  525. when empty; cast + crew (#28). A renamed title is re-indexed for the search at once (`searchTitleRenamed`). Empty TMDB values never
  526. overwrite. TVmaze: the series request is `GET /shows/<id>?embed=episodes` now (show + episodes, one request, was `/episodes`) — its
  527. summary, HTML stripped (`stripHtml`), → `tvmazeSummary`.
  528. * **Summaries**: `summary` = the creator's own text, written by nothing (sync, import, person fill, completion). The page shows
  529. `summary` > `tmdbSummary` > `tvmazeSummary` (`shows.hl summaryOf`). ONE TIME at start (`moveCopiedSummaries`, marker
  530. `storage/mpackdb/summaries-moved.txt`): a `summary` identical to `tmdbSummary` (the migration's copy) is MOVED to `migratedSummary`
  531. (mission 023 (old 070): kept, shown nowhere — nothing is deleted). Live copy
  532. 2026-10-01/02: **9,647 moved, 24 kept** — all 24 are followed shows whose tmdbSummary the #9 sync changed since (older TMDB texts,
  533. probably no rewrites; list in STATUS #18).
  534. * **The last run**: `storage/mpackdb/sync-state.txt` = the UTC day the last FINISHED run started. FULL WALK (followed shows, as
  535. before) when it is missing (the first run after the deploy), more than 14 days old (TMDB's lists reach 14 days), or a change list
  536. fails (then the day is not advanced). Log: `tmdb sync: start, changes since <day> (n days)` / `tmdb sync: start, N followed shows,
  537. full walk (<why>)`, `tmdb sync: changes since …: tmdb tv=… movie=… tvmaze(day)=… → N of our titles (M followed), changeRequests=…`,
  538. `tmdb sync done: mode=delta|full changeRequests=… shows=… … requests=… tvmazeRequests=… errors=… seconds=…`.
  539. * **The tool**: `tools/sync-tmdb.hl --delta [--since YYYY-MM-DD] [--plan]` = the app's run from the command line (app stopped / a
  540. copy); `--plan` only the change lists + the queue; the day is stored only by a plain `--delta` run. Without `--delta`: the full walk.
  541. * Real data (copy 2026-10-01, real TMDB/TVmaze, one day): TMDB tv 3,088 + movie 9,559 changed ids (31 + 96 pages), TVmaze 312 →
  542. **336 of our titles (28 followed)**; the run: 128 change requests + 477 title requests + 29 TVmaze, **511–536 s**, 0 errors, slowest
  543. step 2.5 s, pages meanwhile median 45 ms / p99 0.7 s / max 2.2 s. The old full walk on the same copy: 128 shows, 259 + 128 TVmaze
  544. requests, 312 s. First day only: +79 seasons / +4,475 episodes (unfollowed series never synced since the migration), 24 titles
  545. renamed (TMDB's en-US name, e.g. "Star Wars: Andor" → "Andor", "Kaun Banega Crorepati(कौन बनेगा करोड़पति)" → "Kaun Banega
  546. Crorepati"), 79 TMDB texts, 173 taglines. MEMORY: ~10 MB per synced title are never given back (the runtime, see #28) — a delta
  547. run +3.5 GB RSS, the old full walk +2.8 GB. Details: STATUS.md ticket #18.
  548. ## What it does (mission 005 (old 047): checks after client-side navigation, mobile first, show page)
  549. * **Invisible checks fixed** by re-vendoring Hybriel master ff51cf46 (see "Vendored Hybriel"): the old
  550. build created SVG built in the BROWSER (client-side navigation, expand/collapse, a check click) in the
  551. XHTML namespace — nothing drawn. Server-rendered pages were fine.
  552. * **Mobile first** (`styles.hl`): every rule outside the one `@media (min-width: 40rem)` block is the
  553. phone's (390 px); the block only adds the wide layout. The header is one row on a phone (brand, ident
  554. selector, Log out; signed out — mission 012 (old 056) — the selector + "Log in", " with ident" only from 40rem, nowrap); `/my/unwatched` + `/my/schedule` rows are two lines on a phone (show + date, the
  555. episode below), one line from 40rem.
  556. * **Show page** (`components/show.hl`): phone = title (+ Follow) first, then the poster at the full
  557. content width, then genres/plot/cast (`showMeta` is `display: contents` there, the title row
  558. `order: -1`); desktop unchanged (poster 10rem left). Season header: a caret at the right edge (since
  559. ticket #17: DOWN = closed, `season-row.expanded` turns it UP). Episode rows are not indented (episode check under the
  560. season check). Artists orange (`#ce9178`, token `orange`). Unwatched check = the inverted solid icon:
  561. outline circle + check mark, since ticket #29 in MUTED grey (accent only under the pointer — in accent a page of
  562. them read as "all ticked"); watched = filled accent disc, dark check.
  563. * **Follow toggle** (signed in): "Follow" = the inverted button (accent border), "Following" = filled;
  564. face `showToggleFollow` → `follows.hl` `setFollowed` (new record, `at` = now → top of `/my/shows`;
  565. unfollow deletes the user's records for that show). **Signed out** the checks and Follow are visible;
  566. a click opens the modal "You need to sign in to follow shows and mark episodes." (Close + "Sign in with
  567. ident" = the ident login link) and sends nothing. The modal is `components/modal.hl`, copied verbatim
  568. from components.hybriel.worldapi.org `components/modal/modal.hl` (becbd59, 2026-09-30) — its own
  569. `Style` lands in `/__hl/app.css`.
  570. ## What it does (step 13)
  571. * **`/`** (`components/home.hl`): signed in, four icon tiles (inline SVG, accent) → `/my/unwatched`, `/my/schedule`,
  572. `/my/shows`, `/my/movies` (2×2 on a phone, 4 in a row from 40rem). For everyone: the text about the site (signed out
  573. plus a "Log in with ident" hint), then **Movies** (→ `/movies`) = the 10 latest RELEASED movies (release ≤ today) and
  574. **Shows** (→ `/shows`) = the 10 series whose newest released episode is newest. Tiles: poster (`posterUrlOf`:
  575. the file or `/posters/none`), title, year; a link to `/shows/<slug>` (movies too — same page, same Follow button).
  576. Phone: each row scrolls sideways inside itself (`tile-row`, the page never does); from 40rem a grid of 5.
  577. * **`/movies`, `/shows`** (`components/movies.hl`, `components/allshows.hl`, both compose `components/tilelist.hl`):
  578. every movie by `release` desc (future-dated ones first, undated last — "all … sorted by release date desc"); every
  579. series by its newest released episode (series with none released after them, by their own `release`). 24 per page,
  580. grid of 3 (phone) / 6. **The page is a path segment: `/movies/page/2`**, page 1 = `/movies`; out of range → the last
  581. page, not a number → page 1. NOT `?page=2` (the mission asked for it): a page component cannot read the query
  582. (hybriel#11, query half open) and hl:web's Back (`popstate`) drops the query — every page change would be a full
  583. reload and Back would land on page 1. With the path, paging is a client-side navigation and Back works.
  584. * **Pagination** = components.hybriel's `components/pagination/pagination.hl`, **copied verbatim** to
  585. `components/pagination.hl` (4b4dabe) — re-copy, don't edit. It has no data out (its README, hybriel#87), so
  586. `tilelist.hl` wraps it in `pager-box { on click }`: a click on one of its buttons bubbles there, the button's text
  587. ("Previous", "Next", a number; "…" nothing) gives the page → `navigate()`. Hidden when there is only one page.
  588. `styles.hl` makes its buttons a bit tighter on a phone so "Previous 1 … 99 100 101 … 326 Next" stays one row.
  589. * **`/my/movies`** (`components/mymovies.hl`): the movies the user follows, newest follow first (same rows as
  590. `/my/shows`: small poster, title with year). `/my/shows` now leaves movies out. Shared sort: `follows.hl`
  591. `insertByFollowDesc`.
  592. * **Speed — `catalog.hl`**: built ONCE at start (one scan of shows + episodes: real data ~1 s, server answers
  593. ~1.5 s after launch) into two sorted id lists in memory (`static index`; hand-written merge sort). A page only
  594. slices 24 ids and fetches those shows: real data, server time ≈ 5 ms per page. Kept fresh without a rebuild:
  595. `project.hl` `syncTick` calls `refreshCatalogShow(id)` after each synced show (its seasons/episodes only, ~2.5 ms;
  596. 126 shows 0.3 s); a NEW DAY moves episodes that came out today (each series keeps its upcoming release dates) into
  597. place on the first read. `tools/sync-tmdb.hl` runs with the app stopped → the next start rebuilds.
  598. * **Content warning** (open, not decided here): the migrated movie table holds many adult titles (the old tracker
  599. imported TMDB lists); they show on the public `/` and `/movies`. No `adult` flag is stored.
  600. ## What it does (ticket #17: season caret, skeleton rows, faster opening)
  601. * Caret DOWN while a season is closed, UP while open (`styles.hl` `'season-row.expanded season-caret'` rotate 180°).
  602. * **Opening** a season (`components/show.hl` `on toggleCollapse`): at once, client-side, the caret turns up and
  603. min(episodes, 8) **skeleton rows** (`skeleton-row`: disc, number, title bar in the episode row's shape and height; CSS
  604. shimmer `@keyframes tracker-shimmer`) appear under it; then the face `showSeasonEpisodes(seasonId)` answers the
  605. season's episodes `{ id, episodeNumber, title }` and `filledRows` replaces the skeletons. **Closing** is client-only.
  606. Opened and closed again before the answer → the answer is dropped. An error closes the season again.
  607. * Why it was slow: the old face `showRows` took `session`, and hl:web answers every session face by RE-MOUNTING the whole
  608. page on the server and shipping every session-derived member as `sync` (`plugins/web/WebFramework.hl` `inbound`) —
  609. slug scan, watches scan, all rows twice. `showSeasonEpisodes` takes NO session (public data): no mount, no sync.
  610. * The watch state of the new rows comes from the member `watchedEpisodes` (`watchedEpisodesOf`: per season with a
  611. watched episode `{ all, except }` — `all` = mostly watched, `except` = the other ids), rendered with the page and
  612. re-synced after every watch face. Only this show's, never the user's watch list.
  613. * An ADULT title's page (followers only) still opens seasons through the session face `showRows(slug, collapsed)`:
  614. `showSeasonEpisodes` refuses seasons of adult titles (no user known). Real data has no adult title with seasons.
  615. * Real data (Daily Show S30 142 ep / LWT S12 30 ep, 390 + 1280): episodes visible 380–450 / 235–390 ms (outliers 1.1 s)
  616. before → 230–290 / 55–100 ms after; skeleton after 80–100 / 25–45 ms; close 420–500 / 260–390 → 135–175 / 26–45 ms. STATUS.md ticket #17.
  617. ## What it does (mission 010 (old 054): adult titles, the backfill)
  618. * `shows.adult`: `true` / `false` from TMDB, `null` = not known yet (all 9,453 migrated rows). `shows.hl isPublicTitle` =
  619. `adult == false`. Used by `catalog.hl` (the homepage rows, `/movies`, `/shows`: built from public titles only;
  620. `refreshCatalogShow` adds or removes one) and `search.hl` (`titleOk[id]`; `dbSearch` skips the others — they are not
  621. counted either). "Fetch from web" asks TMDB with `include_adult=false` and keeps only results with `adult: false`.
  622. * Not filtered: `/my/shows`, `/my/movies`, `/my/unwatched`, `/my/schedule` (the user's own follows).
  623. * The show page (mission 012 (old 056), `components/show.hl adultHiddenFor`/`visibleShow`): a title with `adult == true` is "Not
  624. found" for everyone who does not follow it (signed out too; no member carries its row); a follower sees it, and an
  625. Unfollow on the page hides it at once. Unknown (`null`) pages open (they are in no list). The faces `showRows` /
  626. `showToggle*` refuse a hidden title. Needs hybriel ≥ 73267707 for the instant hide (#122); `collapsed` derives from the
  627. slug, never from the session-derived `showRow` (else it snaps back after every session face).
  628. * Every TMDB answer sets the flag: the daily sync (`syncShow`/`syncMovie`), the search import (`importTitle`), the backfill.
  629. * **The backfill** (`project.hl backfillTick` → `tmdbsync.hl backfillTitle`): at start the app collects every title with
  630. `adult` unknown, a `tmdbId` and no `adultCheck`; then ONE title per step on the app's clock (0.1 s tick, shared with the
  631. daily sync, which takes precedence — the backfill waits while a daily run is going on):
  632. `GET /movie|tv/<tmdbId>?append_to_response=external_ids` → `adult`, a missing `imdbId`/`tvdbId`, the poster (w342 into
  633. `posters/<oldId>.<ext>` when the file is missing — NOT for an adult title, mission 012 (old 056); no TVmaze, no seasons). Then 260 ms pause per API request (≤ 40 /
  634. 10 s; poster downloads go to TMDB's image host and are not counted). The lists and the search take the title at once.
  635. TMDB 404 → `adultCheck = 'TMDB 404'` (stays hidden, never asked again); no answer / 429 / 5xx → back of the queue, at
  636. most 2 more tries per start. Persisted every 25 titles (mpackdb's `update` is durable anyway — the gate's resume check
  637. stops it after 6 titles, before any persist). A restart builds the queue from what is still unknown = it resumes.
  638. * Log: `adult backfill: start, N titles without an adult flag`, every 500: `adult backfill: 500/9453 titles, adult=…
  639. notAdult=… unknown=… posters=… ids=… requests=… errors=… retries=… seconds=…`, each failure, then `adult backfill done: …`;
  640. with nothing to do: `adult backfill: nothing to do`.
  641. * Real data (copy of 2026-10-01, real TMDB): 9,453 titles in 99 min — 3,450 adult, 5,866 not, 137 unknown (TMDB 404),
  642. 8,740 posters (459 MB in total); pages during the run avg 0.05–0.16 s, max 4.9 s (a slow TMDB answer blocks). STATUS.md mission 010 (old 054).
  643. ## What it does (mission 014 (old 058): tickets #22–#25)
  644. * **#22 air dates** (`components/show.hl`): every episode row ends with its air date (`episodes.release`, `YYYY-MM-DD`
  645. like /my/unwatched; none → empty) — in `buildRows` (server) and in `showSeasonEpisodes` → `filledRows` (an opened season).
  646. * **#23 season check = all episodes watched** (`seasonAllWatched`): derived from the episode watches on every render, not
  647. from the season's own watch record (a season without episodes still uses that record). Unchecking one episode turns the
  648. season off with the face's answer (`showToggleEpisode` rebuilds the rows), checking it again turns it on. A season click
  649. watches every episode when not all are watched (a partly watched season → all), else unwatches all (`setSeasonWatched`
  650. still writes the season record too — nothing reads it for the check any more).
  651. * **#24 movie watched check** (show page, `isMovie`): the episodes' check icon `#moviewatch` + "Watched"/"Not watched"
  652. under the title (phone: above the poster). Face `showToggleMovie(slug, session)` → watch `{ targetType: 'movie', target:
  653. <the title's shows.db id> }` (new target type; `tools/verify.hl` knows it). Signed out → the sign-in modal ("You need to
  654. sign in to follow movies and mark them watched."). `/my/movies`: "N movies · M watched" and a solid check per watched row.
  655. * **#25 `/genres/<genre>`** (+ `/genres/<genre>/page/<n>`, `components/genre.hl`, composes `tilelist.hl`): the genre by
  656. `urlSegment` (`shows.hl genreBySlug`; case-insensitive fallback), its public movies AND series together, newest first,
  657. 24 per page. Lists in `catalog.hl` (`index.genres`, built in the same start scan; key = a movie's release / a series'
  658. newest released episode, else its release — so the date is comparable across both kinds; `refreshGenres` on every
  659. `refreshCatalogShow`, `rollDay` re-keys the series). Adult/unknown hidden like /movies. Unknown genre → "Not found".
  660. The show page's genre pills link `/genres/<x>`; the old `/genre/<x>` answers 301.
  661. * Real data: TMDB's TV genres differ from its movie genres ("Action & Adventure", "Sci-Fi & Fantasy" vs "Action",
  662. "Science Fiction") — `/genres/Action` is movies only (643), `/genres/Action-Adventure` series only (266),
  663. `/genres/Drama` both (2,665). Not merged (open question for the creator).
  664. ## What it does (mission 016 (old 060): short ids, ticket #27)
  665. Creator: "i wanted to give all our movies, shows, series and persons own short ids like a9s9a … those ids show below the
  666. poster." `shortids.hl` (all of it), `project.hl` (route, backfill clock), the two pages.
  667. * **Field** `shortId` on `shows.db` (movies + series) and `persons.db`: 5 characters `[a-z0-9]`. **Unique across both tables**
  668. through ONE in-memory map (`shortids.hl owners`, short id → `{ kind, id }`), built at start (one scan of each table,
  669. ~0.2 s real data); every id handed out goes through it. Not an mpackdb index: those are per table and cannot span two.
  670. * **Old ids**: the old export had `id` on 702 of 9,453 shows (`Show.jsonl`, base62 counter `100jh`…`1012y`, all lower case).
  671. `data/old-short-ids.json` (old Mongo `_id` = our `oldId` → id; made ONCE by `tools/old-short-ids.hl` from the export on
  672. Loreana; committed, deployed with the code) gives those shows their old id back. All 702 are RESERVED: a new id is never
  673. one of them, even where the show is gone. Persons never had one. Real data: 702 of 702 kept (Foundation `100sp`, Raised by
  674. Wolves `100jh`).
  675. * **New ids**: 5 random characters (hl:crypto, bytes ≥ 252 skipped so all 36 are equally likely), retried while taken or old.
  676. New records get one AT CREATION (`claimShortId`): search import (`search.hl importTitle`) and the person fill's minimal
  677. titles (`people.hl addMinimalTitle`) — the only places the app creates shows; persons are created only by `tools/migrate.hl`.
  678. The daily sync creates seasons/episodes only.
  679. * **Backfill** (records without one — at first all 25,167): in the background, its own `every(0.1)` clock in `project.hl`
  680. (`shortIdTick` → `shortIdStep`): per tick ONE batch of as many records as fit into 40 ms (fetch, set `shortId`, `update`);
  681. persisted every 20 batches and at the end. The queue is built at start from what is still missing → resumes after a
  682. restart. A record whose id another one already holds (never seen; logged "… gets a new one") is re-queued. Log:
  683. `short ids: N records, X with a short id, Y without, 702 old ids, … ms`, `short ids: backfill start …`, every 5000 `short
  684. ids: N assigned, M left, s`, `short ids done: assigned=… oldKept=… batches=… slowestBatchMs=… slowestPersistMs=… seconds=…`,
  685. or `short ids: nothing to do`. Chosen over "all at start": 25k records in one go would block the server ≥ 16 s (a tool on the copy: 1,564 records/s; inside
  686. the app a record costs more, ~3 ms — not found out why).
  687. Real data (copy 2026-10-01): **~300 s**, 1,859 batches, slowest batch 62 ms, slowest persist 31 ms (STATUS.md mission 016 (old 060)).
  688. * **Shown** (`components/show.hl`, `components/person.hl`, `styles.hl shortIdLabel`): `#shortid` under the poster (phone: under
  689. the full-width poster) and under the person's photo — no photo: under the name and facts; small (.8rem), muted, monospace,
  690. centred under the image, one click selects it. None while the backfill has not reached the record yet.
  691. * **`/<shortId>`** (`project.hl shortIdRoute`, the LAST route — every named one-segment route wins): 301 → `/shows/<slug>` or
  692. `/person/<slug>`; upper case is read as lower; unknown / not 5 chars → 404 (plain text). An adult title's page itself then
  693. says "Not found" to non-followers as before.
  694. * Count on a COPY (or app stopped): `TRACKER_STORAGE=… TRACKER_OLD_SHORT_IDS=$PWD/data/old-short-ids.json ./bin/hybriel
  695. tools/count-short-ids.hl` → per kind with/without/malformed, old kept/lost, duplicates across both.
  696. ## What it does (mission 022 (old 068): typed headings, series / shows split, tickets #20 + #21)
  697. Everything (kinds, the backfill, addresses, the `/shows/<slug>` function route, colours): **[docs/kinds.md](docs/kinds.md)**.
  698. Code: `shows.hl` (`kindOfTitle`, `titleKind`, `titlePath`, `typeWordOf`, `typeClassOf`), `catalog.hl` (two TV lists),
  699. `tmdbsync.hl` (`kindTitle`, type stored by the sync / adult backfill), `search.hl` (import), `project.hl` (`showsRoute`, routes,
  700. `kindTick`), components (headings, `allseries.hl`, `myshows.hl` for `/my/:tv`, home), `styles.hl` (`type-*`, `h1.typed`).
  701. ## What it does (ticket #19: franchises and timelines)
  702. Details: [docs/franchises.md](docs/franchises.md). New tables `franchises.db`, `timelines.db`, `collectionchecks.db`
  703. (`franchises.hl`). `/franchises` (list), `/franchises/<slug>` (index: each timeline sortable Timeline | Release — a series by
  704. its LAST episode), `/timelines/<slug>`; on every show/movie page one widget per timeline: franchise above, `‹ Prequel | name |
  705. Sequel ›`. The creator (`TRACKER_EDITORS`, az5b2) edits at `/franchises/edit` and `/timelines/<slug>/edit`. A background seed
  706. turns TMDB movie collections (≥ 2 of our movies) into timelines (one movie per step, ≤ 40 TMDB req / 10 s, resumes).
  707. ## What it does (mission 025: /my/ pages fast after the jobs, tracker#30; timeline name once; widget position; First Contact)
  708. - **Speed (#30)**: the /my/ lists and the poster tiles no longer read whole title records (cast + crew) or an episode per watch on
  709. every load. `shows.hl` keeps three slim caches: `cardOf(id)` (title, year, kind, href, poster name), `episodeRowsOf(id)` (a
  710. title's episodes `{ id s e title release }`) and `episodeKeyOf(id)` (`{ show s e }` of a watched episode); `watches.hl
  711. watchedSetOf(user)` is kept until that user's next watch. Filled at boot for every followed title and watched episode (log
  712. `list caches: 128 followed titles, 11648 watched episodes in ~460 ms`), refreshed by `catalog.hl refreshCatalogShow` (every
  713. writer of a title calls it). Used by /my/series, /my/shows, /my/unwatched, /my/movies, /my/schedule and the tiles (/, /movies,
  714. /series, /shows, genres). Real copy, process after the jobs: /my/series 1.8 s → 0.06 s, /my/unwatched 4.1 → 0.18 s (STATUS).
  715. - **Timeline page**: its name only in the h1 (the list head shows count + sort toggle).
  716. - **Franchise widget**: inside the show header — phone: right under the poster, before genres/plot/cast; ≥ 40rem: right column under
  717. the title/links.
  718. - **Star Trek: First Contact**: its tmdbId 199 is RIGHT (it is /movie/199). The old #9 sync asked `/tv/199` and stored that TV
  719. title's overview, status `Ended` and season/episode counts. The details repair now also takes a movie with season/episode counts
  720. (`details.hl hasTvLeftovers`) and asks `/movie/<id>` again, then drops the counts. Real data: only that one record; 0 records
  721. share a TMDB id with a different title (68 same-type ids are held by 2–6 records of the SAME title — duplicate imports, see
  722. STATUS open points; `tools/count-tmdb-ids.hl`).
  723. ## What it does (mission 026: duplicate titles merged, tracker#31; deploy waits for the boot)
  724. - **Where the duplicates came from**: the OLD tracker. On the live copy of 2026-10-03, 68 type+tmdbId pairs were held by 157
  725. records (Archive 81 ×6, Peripheral ×4, Ally McBeal, The Flight Attendant, 60 adult movies ×2) — every one a migrated row (Mongo
  726. `oldId`, made 2022–2023 in runs of consecutive ids, several sharing one slug). This app's two creating paths already look the
  727. title up first (search import `search.hl importTitle`, filmography fill `people.hl fillStep` → `titleIdByTmdb`), and their
  728. records carry `oldId = tmdb-<tv|movie>-<id>`, which the `!oldId` UNIQUE index refuses twice. No extra index: an mpackdb unique
  729. index refuses a second null (tombstones and titles without a TMDB id) and opening a table with a new index is untested (#21).
  730. Hardened: the import looks the id up again right before its put; a merged tombstone answers its keeper everywhere
  731. (`shows.hl liveShowOf`); `search.hl searchTitleMerged` points the TMDB id at the keeper.
  732. - **Seasons**: 449 migrated seasons name a title that does not list them (the old tracker showed seasons by query). The daily sync
  733. goes by the list, so where TMDB has that number it made a second season — Reacher: the creator's S3 watches sat on the unlisted
  734. migrated S3, the page showed the sync's S3 unwatched.
  735. - **The repair** (`merge.hl`, in the app, own 0.1 s clock, one item per tick, no TMDB; the TMDB jobs wait until it is done):
  736. per duplicate group the KEEPER = the record with follows/watches, else the one with its old short id, else the oldest; follows,
  737. watches, seasons (moved, or merged episode by episode into the keeper's season of that number), cast + crew, persons' credits,
  738. timelines and empty fields go to it; the keeper takes the plain slug of its title when a duplicate held it (Archive 81:
  739. `Archive-82` → `Archive-81`, the former in `oldSlugs`). The others become TOMBSTONES — never deleted: `mergedInto`, `mergedAt`,
  740. `mergedTmdbId` (tmdbId null), `mergedSeasons` (seasons []). A STRAY season with a listed twin is merged into it; one without is
  741. linked into the title's list only when someone watched it (`linkedSeasons`) — linking all 393 with episodes put 1,091
  742. never-watched old talk-show episodes on /my/unwatched. A user who has both a watch/follow on the keeper and on a duplicate keeps
  743. the keeper's; the other row is parked (`target`/`show` null, `mergedInto`) — no row is deleted. Resumes after a restart; a later
  744. start logs `merge: nothing to do`.
  745. - **Redirects**: a tombstone's slug and a keeper's former slug answer **301** to the keeper — `/series/…`, `/movies/…` (function
  746. routes made at boot from `shows.hl movedSlugs`, log `moved slugs: N`; a title merged while the process runs shows the keeper's
  747. page under the old slug until the next start) and `/shows/…` (showsRoute). A tombstone's short id `/<sid>` → 301 at once.
  748. - **Real copy** (STATUS mission 026): 68 groups → 0, 89 tombstones, 29 seasons merged (Reacher S3, Vikings S0 ×3, Raised by
  749. Wolves …), 2 watched strays linked (Supergirl S0, The Peripheral S0), 9 watches moved, 44 credits re-pointed; ~10 s, slowest
  750. step 187 ms. The creator's follows (130) and watches (12,139 rows, 12,136 live) the same before and after; Reacher S3 checked,
  751. /my/series S03E08, /my/unwatched 1,273 → 1,265 rows (Reacher S3 gone); Archive 81: one search hit, one page, all 8 watched.
  752. - **deploy.sh**: the URL check asks every 2 s for up to 90 s (`DEPLOY_URL_WAIT`), printing only changes (`after 14 s: 502` …
  753. `after 30 s: 200`) — the boot (search index over ~100k people, list caches, slug map) answered 502 twice while live was fine.
  754. ## Test
  755. THREE gates, all must pass (deploy.sh runs all three in a row on the default ports 8700–8710):
  756. ```bash
  757. mkdir -p /tmp/shots # TRACKER_GATE_SHOTS must exist before browser.mjs runs
  758. node tests/browser.mjs # the main gate (below) — mission 026: 342 passed, 0 failed (+15: tracker#31 duplicates, stray seasons, redirects, creating paths)
  759. node tests/kinds.mjs # #20/#21: kinds, the kind backfill, /series /shows /movies, typed headings — 32 passed, 0 failed (collection seed off since mission 024 (old 071))
  760. node tests/franchises.mjs # #19: franchises/timelines, seed, widget, editor; mission 023 (old 070): typed headings + /movies|/series links; mission 025: widget position, timeline name once — 52 passed
  761. # a worker on other ports: TRACKER_GATE_SHOTS=/tmp/x TRACKER_GATE_PORT=8750 TRACKER_GATE_IDENT_PORT=8751 TRACKER_GATE_CHROME=8752-8756 TRACKER_GATE_TMDB_PORT=8757 node tests/<gate>.mjs
  762. ```
  763. ```bash
  764. node tests/browser.mjs # THE GATE: this app's own server + its OWN ident (a copy of
  765. # ident's code without .env, codes to a mail sink — no live
  766. # ident, no real mail; tests/identkit.mjs, same as calendar's
  767. # gate) + a real headless Chrome: signed out (header shows the
  768. # selector + "Log in with ident"; since #13 the homepage has
  769. # content, see below) -> sign in -> header shows "Log out" -> sign
  770. # out -> signed out again -> a reload stays signed out. Also
  771. # checks the header has no bottom border and filled buttons
  772. # (incl. the ident selector's) have none, while inverted ones
  773. # ("Log out") keep theirs.
  774. # tracker.worldapi.org#4: a fixture show (tests/seed-show.hl,
  775. # written into the gate's own storage before the server starts)
  776. # proves /shows/:slug — header renders, no checks signed out;
  777. # signed in, click an episode check (turns solid), click a
  778. # season check (itself AND its episodes turn solid, proven
  779. # after a reload — server-side, not just client state).
  780. # tracker.worldapi.org#5: the same fixture (now followed by the
  781. # test user) proves /unwatched — signed out, no rows; signed in,
  782. # only the two already-released episodes show, newest first, the
  783. # far-future one excluded; click a check, the row disappears
  784. # (proven server-side after a reload).
  785. # tracker.worldapi.org#6: the same fixture proves /schedule —
  786. # signed out, no rows; signed in, only the far-future episode
  787. # shows (the two already-released ones excluded), no check icons
  788. # at all, unaffected by any watch toggle on the other pages.
  789. # tracker.worldapi.org#7: the fixture now has a 2nd followed
  790. # show (followed later, no poster name, never watched) and
  791. # proves /my/shows — signed out, the sign-in message; signed
  792. # in, newest follow first, title links, loaded posters (the
  793. # placeholder), S01E02 after /unwatched's click, then S02E01
  794. # (highest, not most recent) after the show-page checks; the
  795. # unwatched show shows no episode.
  796. # tracker.worldapi.org#8: the lists moved to /my/unwatched and
  797. # /my/schedule (old addresses: 301, and the browser lands on
  798. # the new one), rows say S01E02 / S02E01, titles carry the
  799. # year ("Gate Test Show (2024)", "Unwatched Gate Show (2025)"
  800. # — from `release`, that show has no `year`), the show page
  801. # says "1 episode" / "2 episodes", /my/shows gives the show
  802. # with a poster FILE (the gate writes posters/sh1.png) its
  803. # own URL and the other one /posters/none.
  804. # tracker.worldapi.org#9: a FAKE TMDB (tests/faketmdb.mjs, a
  805. # node http server inside the gate, fixed JSON + a png; the
  806. # app/tool get TMDB_BASE_URL/TMDB_IMAGE_URL + a gate token —
  807. # never the real TMDB). App stopped → tools/sync-tmdb.hl: +2
  808. # seasons, +4 episodes, 3 fixture episodes matched by S/E and
  809. # updated, 2 posters, no season 0, Bearer token on every call;
  810. # a 2nd run changes nothing. Restarted: /my/schedule and
  811. # /my/unwatched show the new episodes, /my/shows + the show
  812. # page the downloaded posters (/posters/sh2.png byte-equal),
  813. # S1 keeps its watched episodes, no duplicates. Then the
  814. # app's OWN daily run (TRACKER_SYNC_HOUR = the current UTC
  815. # hour, fake TMDB slowed to 700 ms/request, one new episode):
  816. # it runs, finds the episode, and pages asked meanwhile are
  817. # answered < 2.5 s (between shows) though the run takes ≥ 3 s.
  818. # tracker.worldapi.org#10: the PWA — head links (manifest,
  819. # apple-touch-icon, favicon, theme-color = token darker), the
  820. # manifest (type, name, start_url, scope, standalone, colours),
  821. # every icon a real PNG of its size, touch icon 180, favicon
  822. # svg + ico; Chrome: no installability error, manifest parsed
  823. # without errors; the worker /__hl/sw.js registered, scope /,
  824. # controls the page (a separate 390 px phone tab). OFFLINE:
  825. # CDP offline → the note appears; the server STOPPED too (CDP's
  826. # offline does not reach the worker's own fetches) → reload of
  827. # / shows header + note from the cache; /my/shows gives
  828. # "Unavailable offline"; server back → no note.
  829. # mission 005 (old 047): a CLIENT-SIDE navigation /my/shows → the show
  830. # (window marker proves no reload): every check/caret svg in the
  831. # SVG namespace and drawn — again after expand, an episode click,
  832. # collapse (FAILED on the old vendored build: XHTML namespace,
  833. # circle width 0 — /tmp/w047/old-build-gate.log); caret down/
  834. # right and flips; episode check under the season check; artists
  835. # rgb(206,145,120); unwatched icon = accent outline + accent
  836. # check; Follow: "Following" filled → unfollow ("Follow",
  837. # outlined) → gone from /my/shows, survives reload → follow →
  838. # top of /my/shows; signed out: checks + Follow visible, a click
  839. # (episode, season, Follow) opens the sign-in modal, Close
  840. # closes it, a reload shows nothing was written. 390 px + 1280
  841. # px: header one row, no sideways scroll, show-page order
  842. # (phone: title, full-width poster, genres, plot, cast).
  843. # tracker.worldapi.org#12: link icons only for existing ids
  844. # (show 1: TMDB+IMDb, show 2: TMDB+TVDB, a fixture MOVIE: TMDB
  845. # /movie/), exact hrefs, target _blank + rel noopener, a click
  846. # opens a NEW tab (CDP target, canAccessOpener false) and the
  847. # page stays; also after client-side navigation; phone/desktop
  848. # position. Sync: external_ids come with the details request,
  849. # the movie only via /movie/, a FAKE TVmaze (same fake server,
  850. # /tvmaze/…, 301 like the real one): imdb hit, imdb 404 → thetvdb
  851. # hit; afterwards the stored ids are unchanged (tt0000001, 4002
  852. # — TMDB says otherwise) and the missing ones filled; 2nd run:
  853. # ids=0, no TVmaze request.
  854. # tracker.worldapi.org#13: fixture + 32 never-followed movies
  855. # (Pager Movie 01…30 = 2001-01-01…30, one 2099, one undated) and
  856. # a series with an episode 2021-01-01. Home signed out: no
  857. # icons, the text, headings → /movies /shows, the 10 latest
  858. # RELEASED movies (30…21, not the 2099 one), the series by
  859. # newest released episode; signed in: 4 icons → /my/…, the
  860. # Movies icon → /my/movies (client-side). /movies via the
  861. # heading: page 1 = 2099 + 30…08, "1 2 Next"; Next →
  862. # /movies/page/2 (client-side, 9 left, undated last), "1" →
  863. # /movies, Back → page 2, direct load, page 99 → 2, abc → 1.
  864. # /shows: series only, order, no pagination for one page; a
  865. # tile → show page. Follow a MOVIE on its page → /my/movies
  866. # has it, /my/shows does not. After the sync tool + restart
  867. # /shows reorders (S03E01); after the IN-APP run (TMDB adds a
  868. # released episode to show 2) /shows and the home row put
  869. # show 2 first without a restart. Offline / from the cache
  870. # still has the text and both rows.
  871. # Session-sync (hybriel 64527baa): after each face-driven change
  872. # (/my/unwatched click, expand, episode click, Follow) every row
  873. # appears exactly once.
  874. # tracker.worldapi.org#15: the fake TVmaze also serves
  875. # /tvmaze/shows/<id>/episodes. First runs: lists agree with
  876. # TMDB → nothing taken. Then TVmaze knows more: S3E4's title +
  877. # date filled (TMDB "Episode 4"), S3E5 + a new S4 added, TMDB's
  878. # S3E1 title kept, show 2 numbered differently → skipped;
  879. # idempotent; /my/schedule + show page show them; TMDB catches
  880. # up → adopts the rows by number (no duplicates, TMDB wins).
  881. # tracker.worldapi.org#14: the header magnifier → /search
  882. # (client-side); typing: 1 letter → "at least 2", "gate" →
  883. # 3 titles in order with year/type/link, address /search/gate;
  884. # people ("test act"); case/accents ("GATE tést"); nothing →
  885. # note + Fetch; reload of the address = same results; bad %
  886. # escapes → 200; "/" → space in the address. Fetch from web
  887. # (fake TMDB search/multi): ours (tv 1001, movie 1003) and the
  888. # person left out, w92 thumbs; signed out Add → modal, nothing
  889. # sent. Signed in: Add tv 1006 (details 404) → error, every
  890. # list unchanged (hybriel#121/#122); Add tv 1004 → its page
  891. # with season/episodes/poster/genre/4 id links, exact TMDB
  892. # calls; then in our results and gone from the web list; Add
  893. # movie 1005 → /movie/ only. Screenshots search-{390,1280},
  894. # search-web-{390,1280}, search-signed-out-modal-1280.
  895. # Merge #13 + #14: an imported title is on /movies and /shows
  896. # at once (search.hl → catalog.hl refreshCatalogShow); #13's
  897. # 32 extra fixture titles are 'Pager …' so 'gate' finds only #14's.
  898. # Mission 010 (old 054) (adult titles): tests/seed-adult.hl adds, app
  899. # stopped, 1 adult movie alice follows + 14 titles with an
  900. # unknown flag (fake TMDB: 1 adult, 1 404). Backfill off:
  901. # none on /, /movies (both pages), /shows, the search; the
  902. # adult one on alice's /my/movies + its page; Fetch from web
  903. # drops TMDB's adult result. Backfill on (fake TMDB 300 ms per
  904. # answer): stopped after 6 titles, restarted → only the rest is
  905. # asked (resume), pages < 1.2 s meanwhile, then the lists and
  906. # the search have the non-adult ones at once, the poster is
  907. # served, a 3rd start has nothing to do. Screenshot
  908. # adult-home-1280.png. startServer() runs TRACKER_BACKFILL=0
  909. # unless a block switches it on.
  910. # tracker.worldapi.org#16: person pages (fake TMDB /3/person/<id>
  911. # + combined_credits, w185 photo → fake.personRequests): a
  912. # COMPLETE person at once (facts, rows, TMDB not asked); the
  913. # cast link on the show page → /person/Test-Actor CLIENT-SIDE:
  914. # "Loading filmography…" (TMDB slowed to 900 ms), fills without
  915. # reload: facts + photo, 86 rows (87 titles − adult), order,
  916. # roles merged, posters (file / TMDB w92 / placeholder), ONE
  917. # person request + photo, log steps=4 added=85; bio four lines
  918. # → click → all; row → show page → Back; an added title's page;
  919. # second visit: complete, nothing asked/added; Second Actor:
  920. # linked, added=0, our adult record hidden though TMDB's credit
  921. # is not; search finds the added title once; 390/1280 no
  922. # sideways scroll; screenshots person{,-complete}-{390,1280}.
  923. # Mission 012 (old 056): signed-out header ONE row at 320/390/1280
  924. # ("Log in" on a phone, "Log in with ident" wide; shots
  925. # header-signed-out-{320,390,1280}.png); the backfill does not
  926. # download the adult title's poster; /shows/adult-gate-movie is
  927. # "Not found" signed out (no title in the HTML), alice sees it,
  928. # her Unfollow on the page hides it at once (+ reload,
  929. # /my/movies); an episode check on a normal show page toggles
  930. # and back. Every stopServer() leaves the tab on about:blank first
  931. # (else websocket reconnect errors fail the console check).
  932. # #17: caret up (open) / down (closed) and flips; opening with the
  933. # face held back 1.5 s (`window.__delayFace`, an init script that
  934. # delays that websocket frame): 2 skeleton rows under season 1,
  935. # episode-row shape/height, shimmer animation, caret already up,
  936. # no episode yet (390 + 1280, shots show-skeleton-*); after the
  937. # answer: skeleton gone, every row once (hybriel#121), watch state
  938. # right (show-open-*, show-closed-*); closed while loading stays
  939. # closed; only showSeasonEpisodes is sent (wsSent), closing sends
  940. # nothing.
  941. # mission 014 (old 058): #22 air dates (server + opened season), #23 season
  942. # check off/on live + after reload + partly watched → all, #24 the
  943. # movie check signed out (modal) / in (click, reload, uncheck) +
  944. # /my/movies count, #25 /genres/Action (2 pages, series + movies,
  945. # adult hidden, pill → /genres/Drama, /genre/ 301, imports at once).
  946. # Fixture: genre Action on the Pager titles + adult "Hidden Action Film".
  947. # mission 016 (old 060) (short ids): the gate's own old-id file (sh1 → 100jh,
  948. # 'gone-old' → 100ji reserved); the first start's background
  949. # backfill (oldKept=1); a search import has its id at once;
  950. # #shortid under the poster/photo (person without photo: under
  951. # the facts), monospace, muted, smaller, 390 + 1280 (shots
  952. # shortid-{show,movie,person,person-nophoto}-{390,1280}); the
  953. # fill's new titles have one (the run's backfill was done
  954. # before); /<id> → 301 (show, movie, person, upper case),
  955. # unknown / reserved / 3 chars → 404, the browser lands on the
  956. # page; tools/count-short-ids.hl (app stopped): all have one,
  957. # 0 duplicates; RESUME: tests/seed-shortids.hl adds 60 persons
  958. # without + 2 sharing aaaa1, a start with a 1 ms budget is
  959. # stopped part way, the next does only the rest, one of the two
  960. # keeps aaaa1, the other gets a new one, a 3rd start: nothing.
  961. # #26: Person Film One (opened from Test Actor's filmography,
  962. # client-side, face held back 1.5 s): skeleton (poster, cast
  963. # line), TMDB not asked yet → complete without reload: poster,
  964. # IMDb, cast (Test Actor linked, a NEW person, adult person left
  965. # out); ONE details request; reload complete; the new person's
  966. # page. Person Series: 3 skeleton season rows → in 4 steps (gate:
  967. # TRACKER_DETAILS_PART_EPISODES=1, a season per step) 2 seasons
  968. # with TMDB + TVmaze episodes, 4 links, cast from aggregate_
  969. # credits; open/close; the search finds the new person. Repair job (fake TMDB
  970. # 60 ms, TRACKER_REPAIR_FAST): 82 titles, stopped after 6,
  971. # restart resumes, 404s marked, adult never asked, /movies < 1.2 s
  972. # meanwhile, a 3rd start has nothing to do. Shots
  973. # title-skeleton-1280, series-skeleton-1280.
  974. # #28: the sync tool stores the credits from its details request
  975. # (show 1: cast 23 / crew 7 / +25 people; the movie 3 / 13 / +15;
  976. # the 2nd run adds nobody). Movie page: TMDB cast order with the
  977. # characters, crew by job (a director listed twice: once; 9
  978. # writers → 6 + "Show all crew (13)", client-side, no face).
  979. # Series page: 23 of all seasons by episodes (Test Actor linked,
  980. # not added twice, adult left out), the later-season actor,
  981. # collapsed to 20 + "Show all (23)" (no comma after the 20th) →
  982. # all + "Guest stars" (no face sent), "Show less"; crew Created
  983. # by / Directed by (by episodes) / Written by / Story / Music; a
  984. # new person's page; the completed #26 title's crew; the search
  985. # import's cast + crew. Credits job (fake TMDB 60 ms,
  986. # TRACKER_CREDITS_FAST): TRACKER_CREDITS_MAX_MB=1 pauses it before
  987. # the first title; stopped after 6, resumes, done, /movies
  988. # < 1.2 s meanwhile, a done title's cast + crew, a 3rd start has
  989. # nothing to do. Shots credits-movie-1280, credits-series-
  990. # {collapsed,all}-390, credits-series-1280.
  991. # mission 018 (old 062) (merge of #26/#28 + 3 fixes): count-short-ids after
  992. # the sync tool and after the credits job (app stopped): every
  993. # person has a short id, 0 duplicates; the #26 new cast member,
  994. # the import's Imported Lead / Import Creator have one at once;
  995. # the next start: "short ids: nothing to do". Guests: Guest Star
  996. # One/Two link /person/tmdb/<id>?show=<id>, no person page before;
  997. # a click creates Guest Star One (short id, filled from TMDB:
  998. # Gate Test Show / Stranger), shots guest-person-{390,1280}; the
  999. # link again → 302, no 2nd person; without ?show= the name from
  1000. # TMDB /person/3141; /person/tmdb/999999 + /abc → 404. A watch
  1001. # click's ack + session sync: rows, NOT castRows/crewRows/names,
  1002. # < 20 kB; cast + crew still there after two clicks; after a
  1003. # client-side navigation a watch click answers only ok (rows by
  1004. # the session sync), flips + back, every row once.
  1005. # tracker.worldapi.org#18: the first start clears the seed
  1006. # movie's copied summary (marker file); the first in-app run is
  1007. # the full walk and stores its day. At the end: fake change
  1008. # lists (2 ids per page) + TVmaze updates, day = yesterday →
  1009. # the in-app run asks 2+2 pages + TVmaze, syncs ONLY 1001,
  1010. # the movie (followed) and Person Series (light: details +
  1011. # season/2 only), never 1002/1004; renamed titles on their old
  1012. # slugs, the creator's summary kept, the movie TMDB's new text,
  1013. # Person Series' new episode, search by the new name; records
  1014. # via tests/peek-shows.hl (tmdbSummary, tagline, tvmazeSummary
  1015. # HTML-stripped, untouched tmdbSync); a 503 change list → full
  1016. # walk, day kept; 20 days → full walk; TVmaze text shown when
  1017. # there is no other. Shots delta-{show,movie}-1280.
  1018. # 323 checks (#13 + #14 + #15 + 1 merge check + 17 mission 010 (old 054) + #16: 19 + 5 mission 012 (old 056) + 6 #17 + 21 mission 016 (old 060) + 17 #26 + 18 #28 + 7 mission 019 (old 063) + 10 mission 018 (old 062) + 12 #18). Own servers :8700 (this app) / :8701 (ident copy)
  1019. # / :8710 (fake TMDB); own storage .scratch/gate-store; Chrome
  1020. # on 8702-8709. Other ports (workers get 8720-8739):
  1021. # TRACKER_GATE_PORT=8720 TRACKER_GATE_IDENT_PORT=8721 TRACKER_GATE_CHROME=8722-8726 TRACKER_GATE_TMDB_PORT=8727 node tests/browser.mjs
  1022. # TRACKER_GATE_SHOTS=/tmp/x also writes {home,show,my-shows,
  1023. # my-unwatched,my-schedule,movies,movies-page-2,shows-list,
  1024. # my-movies,genre-action,genre-action-page-2,movie}-{390,1280}.png,
  1025. # movie-watched-{390,1280}.png, movie-signed-out-modal-1280.png, signed-out-modal-
  1026. # 1280.png, synced-*.png (after the sync, 1280×900; synced-show-390
  1027. # + synced-movie-390 at 390) + pwa-phone-
  1028. # online/offline.png (390 px, dpr 2), show-{skeleton,open,closed}-
  1029. # {390,1280}.png (#17) — LOOK at them
  1030. node tests/kinds.mjs # mission 022 (old 068) (#20, #21): its own gate — own storage .scratch/kinds-store, own ident, fake TMDB
  1031. # with TV types (tests/seed-show.hl + tests/seed-kinds.hl): kinds from stored data, the daily
  1032. # sync storing the type, the kind backfill (stop + resume, pacing, 404, nothing to do), the
  1033. # two lists, 301s, every heading's words + computed colours, the function-rendered show page
  1034. # (season, Follow), home rows/tiles, /my/series + /my/shows, search labels + a talk-show
  1035. # import → /shows/<slug>, person labels, 390/1280 no sideways scroll + the long name wrap.
  1036. # TRACKER_GATE_SHOTS=/tmp/w068/shots TRACKER_GATE_PORT=8761 TRACKER_GATE_IDENT_PORT=8762 TRACKER_GATE_CHROME=8763-8765 TRACKER_GATE_TMDB_PORT=8766 node tests/kinds.mjs
  1037. # → 32 passed. Shots {series-page,show-page,movie-page,genre,person,series-list,shows-list,
  1038. # movies-list,home,my-series,my-shows,search}-{390,1280}.png — LOOK at them.
  1039. # The shots dir must exist for tests/browser.mjs (mkdir -p first).
  1040. ps -eo pid,args | grep [h]l-browser-tier # must print nothing afterwards
  1041. ```
  1042. ## Deploy (Byrodin)
  1043. Target: `/CONTAINERS/projects/tracker.worldapi.org` on Byrodin, container
  1044. `tracker.worldapi.org` (`docker-compose.yml`: debian:12-slim, host network,
  1045. `HL_HOST=127.0.0.1`, `TRACKER_PORT=45008`, `TRACKER_WATCH=0`, the folder mounted at
  1046. `/home/tracker`, `./bin/hybriel project.hl`), public https://tracker.worldapi.org/ via
  1047. nginx (TLS ends there; no baseUrl/tls in the app, like ident/notes).
  1048. * **First deploy: done by the architect** (folder, nginx vhost with WebSocket Upgrade
  1049. headers, cert, DNS, **and registering this app in the live ident** — `/apps` → name +
  1050. origin `https://tracker.worldapi.org` → the API key + secret go into `.env` next to
  1051. `docker-compose.yml`, `TRACKER_KEY=… TRACKER_SECRET=…`).
  1052. * **Later: `./deploy.sh`** on Loreana, in this folder: runs the gate (refuses on a failure;
  1053. `--skip-tests` skips it LOUDLY), backs up `storage/`/`.sessions/`/`.env` (what exists) to
  1054. Loreana's `/media/SLOW1TB2/deploy-backups/<app>/` (newest 5 kept; an empty/failed backup stops
  1055. the deploy), then rsyncs the code to
  1056. `[email protected]:/CONTAINERS/projects/tracker.worldapi.org` (never `storage/`,
  1057. `.sessions/`, `.env`, `.scratch/`, `server.*`, logs — the preview is checked for them; no
  1058. `--delete`), `docker compose up -d && docker compose restart` over `ssh -F /dev/null`,
  1059. then waits for https://tracker.worldapi.org/ to answer 200.
  1060. * **Deploy of mission 018 (old 062)** (#26 + #28 first time live): after the start the details repair runs ~14 min (557 titles), then
  1061. the credits job ~50 min (5,867 titles). Watch `docker stats` and the log (`details repair …`, `credits job …`). Real-data
  1062. copy: people 15k → 102k. RSS after boot 4.3 → ~8 GB, swinging 1.2–8.8 GB during the jobs. One step was ~5 s, so pages waited
  1063. that long once. `TRACKER_CREDITS_MAX_MB` (16000) pauses the job.
  1064. * **Deploy of mission 023 (old 070)** (#18 + #20/#21 + #19 + Hybriel 8efba065 first time live; measured on a copy of live `storage/` of
  1065. 2026-10-02 with real TMDB): start ~1 min (search index; ONE TIME the summaries move, 9,647 → `migratedSummary`, ~16 s); adult
  1066. backfill / repair / credits have nothing to do; **kind backfill ~16–21 min** (1,789 TV titles, 1,522 requests), then the
  1067. **collection seed ~32–37 min** (4,774 movies, 4,957 requests, 110 timelines) — ~50 min in all. No `sync-state.txt` yet → the
  1068. first 04:00 UTC run is the full walk; from the next day the delta run (~6–10 min). Log: `kind backfill done`, `collections done`.
  1069. **Memory**: RSS after boot 2–4 GB; during the jobs up to 11.8 GB (swinging back to 1–3 GB). After the jobs the process keeps
  1070. ~25 MB per page load (3 → 19 GB in 600 loads) — **restart the container once after `collections done`** (`docker restart
  1071. tracker.worldapi.org`); a fresh process keeps ~3.5 MB per load, after one daily delta run ~8 MB → a daily restart after the
  1072. 04:00 run is advisable (not built). The old binary kept ~21 MB per load from the start. `/my/unwatched` takes ~5 s (old binary
  1073. 1.2 s): Hybriel's GC by bytes (`HL_GC_BYTES`, README "Config"). Watch `docker stats`. STATUS.md mission 023 (old 070).
  1074. Re-measured in mission 024 (old 071) (live copy of 2026-10-02 10:50): the old binary's jobs took 53 min (peak 13.6 GB), 8590df63 after its
  1075. jobs swings 13–31 GB under page loads — the live process today; whichever binary goes live, restart after `collections done`.
  1076. * **Deploy of mission 026** (tracker#31): the first start merges the duplicates in the background (~10 s on the real copy, log
  1077. `merge: start, 68 duplicate groups (157 records), 15 titles with stray seasons` … `merge done: groups=68 tombstones=89 …`); the
  1078. other jobs wait for it. The moved slugs answer 301 from the NEXT start (`moved slugs: 28`) — restart once after `merge done`
  1079. (`docker restart tracker.worldapi.org`). Every later start: `merge: nothing to do` (~1.2 s scan on the first tick).
  1080. * `./deploy.sh --dry-run` = the gate + `rsync -n` + the commands it would run (no restart, no
  1081. URL check). `--target DIR|HOST:DIR` and `--url URL` point it elsewhere (tested against a
  1082. local directory, see STATUS.md).
  1083. ## Data
  1084. `storage/mpackdb/`: `users.db` (`identity` → this app's own user id, `users.hl`) plus, since
  1085. step 2, the old tracker's data — `genres.db` (27), `persons.db` (15157; #16 adds `biography`, `tmdbProfile`, `adult`, `filmographyAt` and TMDB credits in `shows`), `shows.db` (9453),
  1086. `seasons.db` (6430), `episodes.db` (231584), `follows.db` (128), `watches.db` (11692), all with
  1087. fresh mpackdb ids and every reference re-pointed (`tools/migrate.hl`); the one old user is this
  1088. app's user for ident short id `az5b2`. Since step 9 the TMDB sync adds seasons/episodes and
  1089. `storage/mpackdb/posters/<oldId>.<ext>` (w342, ~50 KB each), and sets `tmdbSync`/`tmdbPoster` on
  1090. shows. #18: `shows.tvmazeSummary` (TVmaze's text, HTML stripped), `summary` = the creator's own text only;
  1091. `sync-state.txt` (the day of the last finished daily run), `summaries-moved.txt` (the one-time move ran: counts). Mission 023 (old 070) (nothing migrated is destroyed): `shows.migratedSummary` (the copied summary), `migratedTitle` / `migratedGenres` / `migratedHomepage` / `migratedTagline` (a MIGRATED record's value before TMDB's first replaced it; set once), seasons/episodes `migratedTitle` / `migratedSummary` (same rule) — kept, shown nowhere. Mission 010 (old 054): `shows.adult` (true/false/null = unknown → hidden from lists + search) and `shows.adultCheck` (why the
  1092. backfill gave up on a title, e.g. `TMDB 404`). Mission 016 (old 060): `shows.shortId` and `persons.shortId` (5 × `[a-z0-9]`, unique across
  1093. both; `data/old-short-ids.json` holds the old tracker's 702). #26: `shows.minimal` (added by a filmography), `detailsAt` (ms, complete),
  1094. `detailsCheck` (`TMDB 404` / `adult`: never asked again), `detailsError` (a TVmaze/poster failure while completing); people added
  1095. from a cast: `oldId tmdb-person-<id>`, `added` (ms). #28: `shows.cast` [{ person, tmdbId, actor, character, profile, episodes,
  1096. guest }] — mission 018 (old 062): a guest's `person` is null until someone opens them (`/person/tmdb/<id>`); `shows.crew`, `creditsAt`,
  1097. `creditsCheck`. Known in the migrated data (not caused by the sync): 12 duplicate episode tmdbIds and 1
  1098. episode whose `show`/`season` disagrees among the followed shows. Counts and the referential-integrity proof:
  1099. `STATUS.md` "ticket #2". Mission 026 (tracker#31, merge.hl): a merged duplicate keeps everything and gets
  1100. `mergedInto` (the keeper's id), `mergedAt`, `mergedTmdbId` (its `tmdbId` is null), `mergedSeasons` (its `seasons` []); a keeper
  1101. may have `oldSlugs` (former slugs, 301) and `linkedSeasons` (stray seasons the repair linked); seasons/episodes merged into a twin:
  1102. `mergedInto` (+ season `mergedEpisodes`), moved ones `movedFrom`; follows `mergedShow`, watches `mergedTarget` (where they were),
  1103. a parked repeat `mergedInto` (+ show/target null). Real data 2026-10-01: 7,814 movies + 1,639 series (`shows.type`), 232,384 episodes (935
  1104. series have a released one).
  1105. ## Files
  1106. | File | |
  1107. |---|---|
  1108. | `CONCEPT.md` | the creator's concept — do not edit |
  1109. | `project.hl` | manifest: the installable app (`appIcons`, `appTouchIcon`, `appFavicon`, theme colours from the tokens, `offline = [ Home ]`, #10), the daily TMDB run (`syncTick`, #9), routes (`/login/callback`, `/login/failed`, `/login.js`, `/posters/:name`, `/movies` + `/movies/page/:page`, `/shows` + `/shows/page/:page` (#13), mission 022 (old 068): `/movies/:slug`, `/series` + `/series/page/:page` + `/series/:slug`, `/shows/:slug` = function `showsRoute` (show rendered, series/movie → 301), `/my/:tv` (series | shows), `/my/movies` (#13), `/genres/:genre` + `/genres/:genre/page/:page` + `/genre/:genre` → 301 (#25), `/my/unwatched`, `/my/schedule`, `/unwatched` + `/schedule` → 301, `/search/:q?` (#14), `/person/:slug` + `/profiles/:name` (#16), `/` component Home, `/:sid` → 301 (mission 016 (old 060), last)), the app's own session cookie; #14: builds the search index at boot (`ensureIndex`); mission 010 (old 054): the adult backfill (`backfillTick`) on the clock it shares with the daily run; mission 016 (old 060): the short-id map at boot (`ensureShortIds`) and its backfill (`shortIdTick`, own clock); mission 022 (old 068): the kind backfill (`kindTick`, shared clock, after the adult backfill) |
  1110. | `users.hl` | the ident exchange + the `usersTable`, copied from calendar.worldapi.org's `users.hl` |
  1111. | `login.js` | bridge between `<ident-selector>`'s `ident-login` event and the shell, copied verbatim from calendar.worldapi.org |
  1112. | `components/home.hl` | `/` (#13): signed in the icon tiles (5 since mission 022 (old 068)); the text; the latest 10 movies, series and shows (catalog.hl) |
  1113. | `components/movies.hl`, `components/allseries.hl`, `components/allshows.hl` | `/movies`, `/series`, `/shows` (+ `/page/:page`): every movie / series / show, 24 per page (#13, #21) |
  1114. | `components/tilelist.hl` | the poster grid + pagination both lists compose; turns a pagination click into `navigate()` (#13) |
  1115. | `components/pagination.hl` | the pagination, copied verbatim from components.hybriel.worldapi.org (`components/pagination/pagination.hl`, 4b4dabe) — re-copy, don't edit |
  1116. | `components/mymovies.hl` | `/my/movies`: followed movies, newest follow first (#13); watched count + check per watched movie (#24) |
  1117. | `components/genre.hl` | `/genres/<genre>` (+ `/page/<n>`): a genre's movies and series, newest first, 24 per page (#25, mission 014 (old 058)) |
  1118. | `catalog.hl` | the public lists: built once at start, refreshed per synced show and per new day (#13); only `adult == false` titles (mission 010 (old 054)); per-genre lists (#25) |
  1119. | `components/main.hl` | the shell (header: brand, `<ident-selector>`, log in / log out; the offline note + its `netProbe` tick, #10) |
  1120. | `components/loginfailed.hl` | `/login/failed`: why a login didn't work |
  1121. | `components/show.hl` | `/series/:slug`, `/movies/:slug`, `/shows/:slug` (via `showsRoute`): the title page, heading "<Type> \| <title>" (#20) (header, Follow toggle, seasons with caret, episodes, watch check; signed out the sign-in modal) |
  1122. | `components/modal.hl` | the modal dialog, copied verbatim from components.hybriel.worldapi.org (`components/modal/modal.hl`, becbd59) — re-copy, don't edit |
  1123. | `components/unwatched.hl` | `/my/unwatched`: unwatched, already-released episodes of followed shows, newest first |
  1124. | `components/schedule.hl` | `/my/schedule`: not-yet-released episodes of followed shows, soonest first, no checks |
  1125. | `components/myshows.hl` | `/my/series` and `/my/shows` (`/my/:tv`, #21): followed series / shows (#13: movies → `/my/movies`), newest follow first — small poster, title, last watched `SxxEyy` |
  1126. | `shows.hl` | read-only data access (shows/seasons/episodes/genres/persons; #28: `showBySlug` via a slug → id map; #18: `summaryOf`; #21: the kind of a title `titleKind`/`kindOfTitle` and its page `titlePath`), plus the list labels `episodeCode`/`titleWithYear`/`posterUrlOf` (#8), reused by the `/my/` pages; `externalLinksOf` (#12: the link icons); `isPublicTitle` (mission 010 (old 054): `adult == false`) |
  1127. | `follows.hl` | per-user follow data (`followsOfUser`, `followedShowIds`), reused by `/unwatched`/`/schedule`/`/my/shows`; `allFollowedShowIds` (#9: every show anyone follows, for the sync); `isFollowing`/`setFollowed` (mission 005 (old 047): the show page's Follow toggle) |
  1128. | `deltasync.hl` | #18: the daily run's plan — the change lists (`planStart`/`planStep`/`planIds`/`planLine`), the last run's day (`sync-state.txt`), `isFollowedInRun`; the one-time `moveCopiedSummaries` (mission 023 (old 070): to `migratedSummary`) |
  1129. | `tests/peek-shows.hl` | #18: the gate reads a title's description fields (`TRACKER_PEEK`; mission 023 (old 070): + `migratedTitle`/`migratedSummary`/`migratedGenres` count, and a title's episodes with `TRACKER_PEEK_EPISODES`), or clears summary + tmdbSummary of one (`TRACKER_CLEAR_TEXTS`) — app stopped |
  1130. | `tools/count-migrated.hl` | mission 023 (old 070): how many shows/seasons/episodes keep a migrated value the sync replaced (`migrated*`) + example titles — ONLY on a copy or the stopped app |
  1131. | `tools/ref-params.py`, `tools/lambda-audit.py` | mission 023 (old 070) (hybriel#48): `python3 tools/ref-params.py . [--apply]` gives every read-only lambda parameter `&`; `python3 tools/lambda-audit.py .` lists the lambdas that change a parameter (a copy since 8efba065) |
  1132. | `tests/realdata-070-{run,sampler,load200,bench}.sh` | mission 023 (old 070), NOT the gate: the app (or the old binary's tree) on a COPY under `.scratch/w070/real-<name>/` with all jobs + a delta run, RSS every 10 s, 200 signed-in loads, a fresh-process bench (STATUS.md mission 023 (old 070) "How to repeat") |
  1133. | `tests/realdata-070.mjs` | mission 023 (old 070), NOT the gate: on a COPY of live `storage/` after the jobs (STATUS.md mission 023 (old 070)), signed in, 390/1280: every page kind (lists, `/franchises`, a franchise + timeline, a movie with the widget, a series with cast, a show, `/my/*`, search) — full load, heading, sideways scroll, screenshot, console problems |
  1134. | `tests/realdata-018.mjs`, `tests/realdata-018-poll.mjs` | #18, NOT the gate: screenshots/times of refreshed titles on a COPY; page latency while the daily run works (STATUS.md #18) |
  1135. | `tmdbsync.hl` | the TMDB sync of one show (`syncShow`: seasons, episodes, poster; #18: `syncShowDelta` (the light step), `tvmazeSummary`, `stripHtml`; #26: `syncShowWith`/`syncShowPart` = with the details answer given, in parts; #12: external ids + TVmaze lookup, movies via `/movie/`; #15: TVmaze episode merge `mergeTvmaze`) + run totals — used by `project.hl`'s daily run and `tools/sync-tmdb.hl` (#9); mission 010 (old 054): `adultOf`, the adult backfill (`adultBackfillQueue`, `backfillTitle`) |
  1136. | `watches.hl` | per-user watch state (episode/season/movie (#24) checks, the shared check-icon class `watchClassOf`, `watchedSet` = the user's watches as a map, built once per page; mission 018 (old 062): `watchesOfUser` kept per user until their next write), reused by `/unwatched`/`/schedule`/`/my/shows` |
  1137. | `icons/` | #10: `icon.svg` (source) → `icon-192.png`, `icon-512.png`, `apple-touch-icon.png`; `favicon.svg`, `favicon.ico` (commands in "What it does (step 10)") |
  1138. | `styles.hl` | all CSS (imports the tokens from `shared/tokens.hl`; accent green `#4ec9b0`) |
  1139. | `shared/tokens.hl` | the WorldAPI tokens, vendored verbatim from `ident.worldapi.org/shared/tokens.hl` |
  1140. | `tests/browser.mjs` | the gate (above) |
  1141. | `tests/kinds.mjs`, `tests/seed-kinds.hl` | mission 022 (old 068): the gate of #20/#21 and its TV fixtures (Talk/Reality/News genres, alice's follows) |
  1142. | `tests/realdata-068.mjs` | mission 022 (old 068), NOT the gate: on a COPY of live `storage/` after the kind backfill: spot checks by name (search label + link), every page kind at 390/1280 signed in (time, heading, sideways), screenshots |
  1143. | `docs/kinds.md` | mission 022 (old 068): kinds, the kind backfill, addresses, the `/shows/<slug>` function route, heading colours |
  1144. | `tests/seed-adult.hl` | mission 010 (old 054): the gate's adult/unknown-flag titles + alice's follow of the adult one, written with the app stopped |
  1145. | `tests/seed-tvleftover.hl` | mission 025: a movie carrying a TV answer's fields (status Ended, counts) for the repair check |
  1146. | `tests/realdata-074.sh`, `tests/realdata-074-bench.mjs` | mission 025: real-copy server (jobs/fresh/stop, tree + copy as args) and the /my/ bench — NOT gates (STATUS "How to repeat") |
  1147. | `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; #12: partial external ids + a MOVIE followed by another user), before the server starts |
  1148. | `tests/identkit.mjs` | starts a throwaway ident copy for the gate, copied from calendar.worldapi.org's `tests/` |
  1149. | `tests/faketmdb.mjs` | the gate's fake TMDB (#9): details, `append_to_response=season/N`, w342 png; records every request; #12: `external_ids`, `/3/movie/<id>`, a fake TVmaze under `/tvmaze` (`tvmazeRequests`); #15: `/tvmaze/shows/<id>/episodes` from `tvmaze.episodes`; #26/#28: `credits` / `aggregate_credits`, a series' `created_by`; #18: `/3/tv|movie/changes` (paged, `changesFail`), `/tvmaze/updates/shows`, `/tvmaze/shows/<id>?embed=episodes` + `summaries` |
  1150. | `tests/realdata-check.mjs` | NOT the gate: the real-data browser check on a COPY of live `storage/` (STATUS.md, mission 005 (old 047) / #13): times of every page incl. `/`, `/movies`, `/shows`, `/my/movies`, pagination clicks + Back, client-side navigation, screenshots to `$REAL_SHOTS`; env REAL_PORT / REAL_CHROME / REAL_OUT / REAL_SHOW (#15) |
  1151. | `search.hl` | #14: the search — in-memory index (`ensureIndex`, `dbSearch`), `queryOfParam`, TMDB `webSearch`, `importTitle` (new record + `syncShow`); #18: `titleIdByTvmaze`, `searchTitleRenamed` (stale entries skipped) |
  1152. | `components/search.hl` | #14: `/search` + `/search/<text>` — the field, our titles/people as you type, "Fetch from web", "Add" (signed out: the sign-in modal) |
  1153. | `people.hl` | #16: person by slug, facts, filmography rows, the step-by-step TMDB fill (`personFillStep`); #26: minimal titles get `minimal = true`, `castPersonId` (a cast member → our person, new ones added, with a short id — mission 018 (old 062)); mission 018 (old 062): `guestPersonId` (a guest star → a person on the first open) |
  1154. | `components/person.hl` | #16: `/person/<slug>` — photo, facts, bio, filmography; "Loading filmography…" + the fill without reload |
  1155. | `tests/realdata-search.mjs` | #14, NOT the gate: the search on a COPY of live `storage/` with the real TMDB (STATUS.md "ticket #14"): keystroke times, web search, ONE import, screenshots |
  1156. | `tests/realdata-person.mjs` | #16, NOT the gate: person pages on a COPY of live `storage/` with the real TMDB (STATUS.md "ticket #16"): cast click → filled times, other requests' wait, second visit, screenshots |
  1157. | `tests/realdata-056.mjs` | mission 012 (old 056), NOT the gate: the merged app on a COPY of live `storage/` after the adult backfill (STATUS.md mission 012 (old 056)): every public page type signed out + /my/* signed in at 390/1280 (times, header one row, screenshots), all list/search/person links → `slugs.txt`, an adult page signed out/in |
  1158. | `tools/count-external-ids.hl` | #12: how many shows have each external id, per type, all / followed — ONLY on a copy |
  1159. | `tools/count-tmdb-ids.hl` | titles sharing a TMDB id, movies with a TV answer's leftovers — ONLY on a copy (mission 025) |
  1160. | `tools/count-duplicate-ids.hl` | duplicate `@id`s per table — ONLY on a copy (hybriel#113 check, mission 005 (old 047)) |
  1161. | `tests/realdata-058.mjs` | mission 014 (old 058), NOT the gate: on a COPY of live `storage/` (STATUS.md mission 014 (old 058)), signed in, 390/1280: full loads of a show, a movie, `/genres/<g>` + page 2, `/my/movies`; air dates, season check == its episodes, genre kinds, one movie-check click + reload (and back) |
  1162. | `shortids.hl` | mission 016 (old 060) (#27): the short ids — old ones from `data/old-short-ids.json`, the map over shows + persons (`ensureShortIds`), `claimShortId` for new records, the backfill batch `shortIdStep`, `shortIdPath` for `/<shortId>` |
  1163. | `data/old-short-ids.json` | mission 016 (old 060): the old tracker's 702 short ids (`oldId` → id), made by `tools/old-short-ids.hl`; deployed with the code |
  1164. | `tools/old-short-ids.hl` | mission 016 (old 060), one-off: the old export's `Show.jsonl` → `data/old-short-ids.json` (reads only the export) |
  1165. | `tools/count-short-ids.hl` | mission 016 (old 060): short ids counted (with/without, old kept/lost, duplicates across shows + persons) — ONLY on a copy or app stopped |
  1166. | `tests/seed-shortids.hl` | mission 016 (old 060): the gate's resume/duplicate fixture (60 persons without a short id, 2 sharing `aaaa1`), written with the app stopped |
  1167. | `tests/realdata-063.mjs` | ticket #29, NOT the gate: on a COPY of live `storage/` (STATUS.md ticket #29), signed in, 390/1280: every check's class + computed look on `/my/unwatched`, a show page, `/my/movies`; screenshots; one `/my/unwatched` click + reload |
  1168. | `tests/realdata-060.mjs`, `tests/realdata-060-backfill.sh` | mission 016 (old 060), NOT the gate: on a COPY of live `storage/` (STATUS.md mission 016 (old 060)): page times + short id under the image + `/<id>` redirects at 390/1280; the backfill run timed with curl |
  1169. | `tests/realdata-057.mjs` | ticket #17, NOT the gate: on a COPY of live `storage/` (STATUS.md #17), signed in, 390/1280: opening/closing seasons of big shows timed in the page (skeleton shown, rows there, skeleton gone), skeleton count, faces sent; then client-filled watch state == server-built |
  1170. | `details.hl` | #26: a title's full details — `isIncomplete`/`needsDetails`, `completeStep` (details + cast, then a series' parts via `syncShowPart`), the repair queue; #28: cast + crew from a details answer (`castOf`, `crewOf`, `withCredits`, `applyCredits` for the import), the credits job's `creditsQueue`/`creditsStep`; mission 018 (old 062): guests stored on the title, no person record (`mergedCast`); #18: `withDetailsFields` (the record's fields, shared with the completion), `applySynced` (the daily sync: fields + credits) |
  1171. | `tests/realdata-062.mjs` | mission 018 (old 062), NOT the gate: on a COPY of live `storage/` (STATUS.md mission 018 (old 062)), signed in, 390/1280: a series + a movie (full load, HTML size, cast/crew/guest counts, screenshots), a guest star click → its new person page (time, screenshot), then 6 watch + 2 follow clicks on Picard and The Simpsons (ms + bytes received each) |
  1172. | `tests/realdata-028.mjs` | #28, NOT the gate: on a COPY of live `storage/` (STATUS.md #28): given titles at 390/1280 — full load, HTML size, cast/crew as shown, "Show all" time (faces sent: 0), screenshots collapsed/all |
  1173. | `tests/realdata-026.mjs` | #26, NOT the gate: on a COPY of live `storage/` with the real TMDB (STATUS.md #26): person page → an incomplete movie/series → skeleton / complete / poster times, /movies latency meanwhile, reload, screenshots; `REAL_TITLES` opens given slugs directly |
  1174. | `tools/check-public-slugs.hl` | mission 012 (old 056): the slugs `tests/realdata-056.mjs` collected → adult true/false/unknown counts, a user's followed adult titles, poster files of adult vs other titles — ONLY on a copy |
  1175. | `franchises.hl` | #19: franchises, timelines (tables, `byShow`), the pages' data, the widget (`widgetsOf`), the editor operations (`isEditorSession`), the TMDB collection seed (`seedStep`) — docs/franchises.md |
  1176. | `components/franchises.hl`, `components/franchise.hl` | #19: `/franchises`; `/franchises/<slug>` + `/timelines/<slug>` (one component, sort toggle client-side) |
  1177. | `components/franchisewidget.hl` | #19: the `‹ Prequel | Timeline | Sequel ›` widget, composed by `components/show.hl` |
  1178. | `components/franchiseedit.hl`, `components/timelineedit.hl` | #19: the creator's editor (`/franchises/edit`, `/timelines/<slug>/edit`) |
  1179. | `tests/franchises.mjs`, `tests/seed-franchises.hl` | #19: the franchise gate + its 7 collection movies |
  1180. | `tests/realdata-066.mjs` | #19, NOT the gate: on a COPY after the seed: `/franchises`, matching timelines (REAL_MATCH), a movie's widget, the creator's editor; times + screenshots |
  1181. | `merge.hl` | mission 026 (tracker#31): the duplicate merge — queue (`mergePrepare`: duplicate groups, stray seasons), `mergeStep` (one group / one title), keeper choice, moving follows/watches/seasons/credits, tombstones; run by project.hl `mergeTick` |
  1182. | `tests/seed-dupes.hl`, `tests/paths-m026.hl` | mission 026 gate fixture (Dupe Gate Series ×3, Dupe Gate Movie ×2, Stray Gate Series with stray seasons) and the creating-paths tool (search import + filmography fill against the fake TMDB, app stopped) |
  1183. | `tests/realdata-m026.sh`, `tests/realdata-m026.mjs`, `tests/realdata-m026-counts.hl` | mission 026, NOT the gate: start this tree on a COPY with every TMDB job off; the creator's pages (Reacher, Archive 81, /my/*) + screenshots; the before/after numbers (STATUS mission 026) |
  1184. | `tests/cdp.mjs`, `tests/ports.mjs` | the CDP browser driver, copied from `ident.worldapi.org/tests/` |
  1185. | `docker-compose.yml`, `Dockerfile`, `deploy.sh` | Byrodin container; the deploy from Loreana (section "Deploy") |
  1186. | `tools/migrate.hl` | one-off: old MongoDB export → `storage/mpackdb/` (step 2, idempotent, see `STATUS.md`) |
  1187. | `tools/verify.hl` | one-off: proves the migrated data against a COPY of `storage/mpackdb/` (counts, zero dangling references, one show end to end) |
  1188. | `tools/sync-tmdb.hl` | the TMDB sync from the command line (#9) — app stopped or a copy; `--limit N`; #18: `--delta [--since DAY] [--plan]`; prints counts |
  1189. | `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)" |
  1190. ## Vendored Hybriel
  1191. `bin/hybriel` + `plugins/` = **hybriel master 06617221** (mission 027, 2026-10-03; sha256 `af6dfcea08a3ae13e6ab4d6eeaada3609630fa739d92fab49c0095f05c733400`):
  1192. the plugin allocator fixes 3a781359 + 413f60e4 (#126, every plugin allocates through plugin_api's malloc) + mpackdb frees per operation (2cb7ae5e)
  1193. + event order by queue stamp (f0ac2d2d, new plugin ABI field — copy ALL plugin .so together). Real copy: RSS through the first-start jobs
  1194. + 400 loads flat at 2.54–2.59 GB (190aa11d: 2.3 → 5.6 GB), page times equal or better (STATUS mission 027). No lambda change.
  1195. The previous vendor 190aa11d (sha256 `feb597c4…`, deployed with dc40d85) is in `.scratch/pre-027/`. Older history:
  1196. 8efba065 (mission 023 (old 070), 2026-10-02; includes #126 f685f240 — the GC also collects
  1197. by bytes, `HL_GC_BYTES` — and #48 4371b7aa — a lambda PARAMETER copies its argument, `&p` = a reference; also #112 #117 #119
  1198. #120, nothing to adopt), `bin/hybriel` sha256 `50361e954e317a819aa58adfc2e3553f0b4b7adc82abd1fad79385d33b3d5b72`. No local
  1199. patch. **#48 in this app**: every lambda parameter a body only READS is declared `&` (319 of them, by
  1200. `tools/ref-params.py`; `tools/lambda-audit.py` lists the ones that change a parameter; the copy per call made `/my/schedule` 5× and `/my/unwatched` 2.5× slower). The 13 lambdas that
  1201. change a passed record/list (`withCredits`, `withDetailsFields`, `addSum`, `firstStep`, `storeOrder`, `startFill`, `fillStep`,
  1202. `mergeTvmaze`, `syncMovie`, `addTotals` — mostly through a local `let x = p`) keep the copy: every caller uses the RETURNED
  1203. value (audit: STATUS mission 023 (old 070)). A NEW lambda: `&` for a record/list/map it only reads.
  1204. The previous build (8590df63, sha256 `ca830ef8…`) is in `.scratch/pre-070/`. **Candidate not adopted** (mission 024 (old 071)):
  1205. master 190aa11d (sha256 `feb597c4…`) in `.scratch/w071/vendor-190aa11d/` — to adopt, copy its bin/ + plugins/ over these, run the
  1206. 3 gates (green on it 2026-10-02); `.scratch/pre-071/` = 8efba065 again. Measure big pages old vs new with `tests/realdata-071.sh`
  1207. + `tests/realdata-071-bench.mjs` (STATUS mission 024 (old 071) "How to repeat"). Built read-only from an archive
  1208. (never in the hybriel checkout; the same with `hybriel-047` → `hybriel-056` for the older build):
  1209. ```bash
  1210. git -C /media/STORAGE/projects/hybriel archive master | tar x -C .scratch/hybriel-047
  1211. cd .scratch/hybriel-047/native && /media/STORAGE/projects/hybriel/native/zig-toolchain/zig build -Dtarget=x86_64-linux-gnu.2.39 -Doptimize=ReleaseFast # → zig-out/bin/hybriel (hybriel README + ReleaseFast)
  1212. cp zig-out/bin/hybriel ../../../bin/hybriel
  1213. # plugins: copy every plugin dir the app already has (core crypto data fetch fs http http1 mpackdb proc smtp
  1214. # time web) from .scratch/hybriel-047/plugins/ over plugins/ — `diff -rq plugins .scratch/hybriel-047/plugins`
  1215. # must then list only plugins the app does not use
  1216. ```
  1217. Older builds: `.scratch/pre-056/` (ff51cf46, sha256 `55a724a0…`) and `.scratch/pre-047/`
  1218. (837fe120 via ident, sha256 `9e5e95b3…`), bin/ + plugins/ each. After a re-vendor run the gate.
  1219. ## History and worker briefs
  1220. - `LOG.md` — append-only history, one dated line per step (moved here from the antcolony LOG on 2026-10-01).
  1221. - `missions/NNN-*.md` — worker briefs for this app; `reports/NNN-*.md` — their reports (same name). Numbered per
  1222. project since 2026-10-01 (antcolony#40); older text, code comments and commits use the old antcolony numbers →
  1223. map: `/media/STORAGE/projects/antcolony-docs/docs/mission-map.md` (Byrodin: `/CONTAINERS/projects/antcolony/docs/mission-map.md`).

Branches

Latest commits

  • d924f398tracker mission 028: comments name the new files (sync.hl, dailysync.hl, backfill.hl, credits.hl, jobs.hl, images.hl …); tools/ref-params.py + tools/lambda-audit.py also scan lib/ (they globbed the root only), lambda-audit counts a plain `x = p` alias like `let x = p`mre
  • 2e89b968tracker mission 028 (code order) 5/5 let: `let` only where a variable is reassigned — 667 never-reassigned lets became plain declarations (project.hl, lib/, components/, tools/, tests/); kept: 264 in loop bodies (a plain declaration there is 'Cannot reassign' on the 2nd pass), 234 reassigned, 27 whose name is also a member/outer/free name (a plain write would rebind it); tools/let-audit.py decides and fixes (README 'Code order'); tests/realdata-m028.{sh,mjs} = the page-output diff on a real copy; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
  • 54796ff2tracker mission 028 (code order) 4/5 thin faces + last copies: the show page's check/follow faces call lib/watches.hl toggleWatched / toggleSeasonWatched (seasonAllWatched moved there) and lib/follows.hl toggleFollowed; both logins (header selector face, /login/callback) share lib/users.hl userOfCode; todayStr/listOf copies in components and the export readers copied into tools/migrate.hl + tools/old-short-ids.hl now once (lib/util.hl, lib/export.hl); gates 342/0, 32/0, 52/0; old-short-ids output byte-identical, migrate output identicalmre
  • 06b078e3tracker mission 028 (code order) 3/5 project.hl is the map: config, routes, wiring and a feature → file index (914 → 258 lines); the background jobs (daily sync run, backfills, details repair, credits job, merge, short ids, collection seed) moved unchanged into lib/jobs.hl (a class: their state is reassigned every step, a static cannot be; one instance made after the server), the login callback into lib/users.hl, poster/photo serving into lib/images.hl, the /shows/<slug> rule into lib/shows.hl showsMovedPath; route handlers are thin wrappers; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
  • 94716fd2tracker mission 028 (code order) 2/5 util + topics: lib/util.hl holds envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc once (were copied into up to 5 files); tmdbsync.hl split into tmdb.hl (TMDB/TVmaze requests), sync.hl (one title's sync), sync-helpers.hl, backfill.hl; details.hl split into details.hl, credits.hl, credits-helpers.hl (isIncomplete to shows.hl); search-helpers.hl (words, query, ranking, slugs); collections.hl (the TMDB collection seed, out of franchises.hl); deltasync.hl renamed dailysync.hl; no behaviour change: gates 342/0, 32/0, 52/0, real-copy pages identicalmre
  • 186079b0tracker mission 028 (code order) 1/5 move: every root .hl except project.hl into lib/ (styles.hl into components/), import paths only; gates 342/0, 32/0, 52/0; real-copy pages identicalmre
  • 4f47f181tracker: report 027mre
  • dc1d4be4tracker mission 027: Hybriel master 06617221 vendored (plugin allocator fixes 3a781359 + 413f60e4); real copy RSS through first-start jobs + 400 loads flat ~2.55 GB (190aa11d 2.3 -> 5.6 GB), page times <= 1.1x; gates 342/0, 32/0, 52/0mre
  • 84e1b3e1tracker: reports 025 + 026mre
  • dc40d859tracker#31 (mission 026): duplicate titles merged — the 68 type+tmdbId pairs held by 157 records were the old tracker's (all migrated); merge.hl repair job (own clock, before the TMDB jobs) keeps one keeper per title (follows/watches > old short id > oldest), moves follows, watches, seasons, cast, credits, timelines, tombstones the rest (mergedInto, never deleted), slugs + short ids 301 to the keeper; stray seasons merged into their listed twin (Reacher S3 watches) or linked when watched; search import re-checks before its put; deploy.sh waits up to 90 s for 200; real copy 68 -> 0 dup ids, az5b2 follows/watches equal; gates 342/0, 32/0, 52/0mre
  • 2667da05tracker#30 (mission 025): /my/ pages from slim cached title cards, episode rows and watch sets (after the jobs /my/series 1.8 s -> 0.06 s, /my/unwatched 4.1 -> 0.18 s); timeline page shows its name once; franchise widget under the poster/title; movies with TV leftovers (First Contact) go through the details repair; tools/count-tmdb-ids.hl; gates 327/0, 52/0, 32/0mre
  • 3909810dantcolony#40: mission references in README/STATUS/docs point to the moved missionsmre
  • e63b1d28antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 1cda451dtracker: Hybriel master 190aa11d (#127 both shapes, GC correctness fc838894) — conductor adopts despite /my/series 2.4x after jobs (memory 8.0 → 1.7 GB boot); see reports/071mre
  • c1fa2f2etracker (mission 071): Hybriel master 190aa11d measured on the real copy vs the live binary 8590df63 — NOT adopted (after the first-start jobs /my/series 2.4x slower, /series 1.6x, RSS swings 7.4-12.2 GB; fresh it is flat at 1.7-2.2 GB and /my/unwatched faster), vendor stays 8efba065, candidate kept in .scratch/w071/vendor-190aa11d; tests/kinds.mjs: collection seed off (its TMDB request broke check 1 in 1 of 4 runs); tests/realdata-071.sh + realdata-071-bench.mjs + tools/realdata-071-table.py; README + STATUS (numbers, how to repeat); gates 325/0, 32/0, 50/0mre
  • 8081350atracker docs (mission 070): README (summary, Config HL_GC_BYTES — kept at Hybriel's default, the 256 MiB setting is taken out of docker-compose.yml again: the jobs grew to 12+ GB with it too, see STATUS), Test (three gates), Deploy (first start ~50 min: kinds then seed, restart once after collections done, memory numbers), Vendored Hybriel 8efba065 + #48 audit, Files; STATUS mission 070 entry (merges, migrated counts, lambda audit, gates, RSS old vs new, how to repeat, open points); docs/kinds.md + docs/franchises.md job order; tests/realdata-070-*.sh, tools/count-migrated.hl, tools/ref-params.py, tools/lambda-audit.pymre
  • 1ad19c8ctracker: re-vendor Hybriel master 8efba065 (#126 GC by bytes, #48 lambda parameters copy) (mission 070): bin/hybriel sha256 50361e95…, plugins core crypto data fetch fs http http1 mpackdb proc smtp time web; lambda audit: 13 lambdas change a passed record/list (11 through a local alias), no caller relies on it — unchanged; 319 read-only lambda parameters get & (no copy per call: /my/schedule 2.35 → 0.46 s, /my/unwatched 13.7 → 5.7 s on the real copy); /my/unwatched one merge sort instead of n² inserts; docker-compose HL_GC_BYTES=268435456; deploy.sh runs kinds.mjs + franchises.mjs too (default ports 8700–8710); tests/realdata-070.mjs; gates browser 325/0, kinds 32/0, franchises 50/0mre
  • 46b21389tracker (mission 070, conductor): the sync never destroys migrated data — the one-time summary step MOVES a copied summary to migratedSummary (marker summaries-moved.txt) instead of clearing it; a migrated record's first title/genres/homepage/tagline TMDB replaces → migratedTitle/migratedGenres/migratedHomepage/migratedTagline, a migrated season's/episode's title/summary → migratedTitle/migratedSummary (set once); tests/peek-shows.hl prints them; gate 325/0mre
  • 749019b0Merge t19 (tracker#19 franchises + timelines) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs/faketmdb.mjs, both sides kept; franchise/timeline pages get #20's typed heading (Franchise | …, Timeline | …), their title links via titlePath (/movies|/series|/shows); the collection seed waits for repair, kinds and credits too; franchises.mjs URLs + 2 new checks; gates browser 323/0, kinds 32/0, franchises 50/0mre
  • daf49feaMerge t20 (tracker#20 typed headings + #21 series/shows split) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/search.hl/components/search.hl/browser.mjs/faketmdb.mjs, both sides kept; clock order backfill → repair → kinds → credits; withDetailsFields stores tmdbType + kind; gates: browser.mjs URLs → /series|/movies, typed h1 selectors; kinds.mjs repair/credits off, fixture name = seed name; browser 323/0, kinds 32/0mre