gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitdcc5eecadcc5eecaMerge branch 't14-search'mredcc5eeca/README.md

55.6 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. Written in **Hybriel** on **hl:web**, same stack and conventions as `ident.worldapi.org`
  48. (vendored plugins/binary copied from there, see "Vendored Hybriel").
  49. ## Run (dev, Loreana)
  50. ```bash
  51. cd /media/STORAGE/projects/tracker.worldapi.org
  52. TRACKER_PORT=8700 TRACKER_SYNC=0 setsid nohup ./bin/hybriel project.hl > server.log 2>&1 < /dev/null & echo $! > server.pid
  53. # TRACKER_SYNC=0: the runtime loads .env (real TMDB token) by itself — without it a dev server syncs from TMDB at 04:00 UTC
  54. # stop: kill $(cat server.pid)
  55. ```
  56. * Config (env; the real environment outranks nothing here — there is no `.env` reader in
  57. project.hl, unlike ident's SMTP settings — set these in the shell or `docker-compose.yml`):
  58. | Variable | Default | |
  59. |---|---|---|
  60. | `TRACKER_PORT` | 45008 | |
  61. | `TRACKER_URL` | `http://127.0.0.1:<port>` | this app's own origin — the ident login button's `return=` is built from it |
  62. | `IDENT_URL` | `https://ident.worldapi.org` | |
  63. | `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 |
  64. | `TRACKER_WATCH` | on | `0` = no dev watcher (the container) |
  65. | `TRACKER_STORAGE` | `./storage/mpackdb` | the `usersTable` directory |
  66. | `TRACKER_SESSIONS` | `./.sessions` (hl:web's own default) | this app's own session store |
  67. | `HL_HOST` (or `HOST`) | 0.0.0.0 | interface to bind; `127.0.0.1` on Byrodin |
  68. | `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` |
  69. | `TRACKER_SYNC` | on | `0` = no daily TMDB run inside the app |
  70. | `TRACKER_SYNC_HOUR` | 4 | UTC hour of the daily run (hl:time has no time zones; the container is UTC) |
  71. | `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 |
  72. | `TVMAZE_BASE_URL` | `https://api.tvmaze.com` | #12: the TVmaze id lookup; the gate points it at its fake (`<fake>/tvmaze`) |
  73. Without `TRACKER_KEY`/`TRACKER_SECRET`, `/` still renders (signed out, the selector and "Log
  74. in with ident" show in the header); a login attempt answers `'login is not set up on this
  75. server (TRACKER_KEY / TRACKER_SECRET missing)'` instead of exchanging a code — so the app is
  76. never a dead 500 while waiting for that one-time setup.
  77. ## What it does (step 1)
  78. * **Login, copied unchanged from calendar.worldapi.org** (rejected once for a centered
  79. sign-in and no header selector — architect, 2026-09-27): ident only, no own passwords
  80. (ident's login button flow, ident.worldapi.org README "How apps use ident"), the identity
  81. selector (`<ident-selector>`, ident's `/selector.js`) sits in the header
  82. (`components/main.hl`, `userBox`), a plain "Log in with ident" link beside it when signed
  83. out. Choosing an identity in the selector fires `ident-login` (one-time code); `login.js`
  84. (an external-component bridge, allowed per the creator: "login.js is correct, as thats for
  85. externals in general") hands the code to a hidden input, whose `change` calls the server
  86. face `trackerLogin` (`users.hl` `exchangeCode`, `POST <ident>/api/exchange` — nothing else:
  87. ident does not hand over a display name yet, ident#23 done / ident#11 on hold), which makes
  88. or finds this app's own user record (`usersTable`, `storage/mpackdb/users.db`) and sets
  89. `session.user = { id }`. `/login/callback` is the same exchange as a plain redirect (the
  90. login button's return URL) for a client that has no socket yet. "Log out" in the header
  91. calls `trackerLogOut`; ident's own login is untouched. This app's own session cookie is
  92. `trackersid` (cookies ignore ports, an own name keeps it apart from ident's `identsid` on
  93. the same dev host, hybriel#10/#17).
  94. * **The empty homepage** (until #13 — see "What it does (step 13)") (`/`, `components/home.hl`): no content at all, signed in or out —
  95. the header alone carries the login state. Nothing else.
  96. * **One mpackdb table**: `storage/mpackdb/users.db` (`identity` → this app's user id — no
  97. display name, no other per-user data yet). Step 2 (tracker.worldapi.org#2, open) brings the
  98. old tracker's data across.
  99. ## What it does (step 4)
  100. `/shows/:slug` (`components/show.hl`, slug = the migrated `urlSegment`), read-only data from
  101. `shows.hl`, per-user watch state from `watches.hl` (both reused as-is by `/unwatched` and
  102. `/schedule`, tickets #5/#6 — "reuse, don't copy"):
  103. * **Header**: poster on the left (`/posters/<name>`, `project.hl` `posterRoute` — served from
  104. `storage/mpackdb/posters/`; the real poster files were never in the Mongo backup, see
  105. `DECISIONS.md` "tracker concept" — a placeholder SVG shows until a later TMDB step brings
  106. real ones back), title, genre pills (blue, link to `/genre/<slug>`) and the plot summary to
  107. its right, the cast below as comma-separated red links to `/person/<slug>`.
  108. * **Seasons**, latest first, the latest expanded and every other one collapsed to start; each
  109. row: a round check icon (a disc with a check, drawn in SVG/CSS, no icon font), the season
  110. title/number, its episode count. Its episodes (number + title, and their own check icon)
  111. show once expanded (click the row to toggle).
  112. * **The watch check** (signed-in only — signed out, no check icons show at all): clicking an
  113. episode's check toggles that one watch; clicking a season's check toggles the season's own
  114. watch AND every one of its episodes at once (`watches.hl` `setSeasonWatched`). Every click is
  115. a server round trip (`emit server showToggleEpisode` / `showToggleSeason` / `showRows` for
  116. collapse/expand) that rebuilds the row list server-side and reassigns it whole — a season has
  117. at most a few dozen episodes, cheap. A **Hybriel gap found here** (filed as hybriel#115,
  118. reported after this ticket's first attempt): a plain per-instance member function that calls
  119. an hl:mpackdb-backed import comes back `null` when called from inside an `on server` face,
  120. even though the very same call works as a member's own top-level initializer — the fix is to
  121. declare such helpers `static` (the same shape calendar.worldapi.org's `soonOf` already uses).
  122. `components/show.hl`'s own helpers (`buildRows`, `genreRowsOf`, …) are `static` for exactly
  123. this reason.
  124. * **Data fix**: `tools/relink-episode-seasons.hl`, a one-off idempotent tool that links every
  125. episode whose `season` was still null in the migrated data (36,194 of them, ticket #2) to its
  126. season by matching `(show, seasonNumber)` — never touches an episode whose season is already
  127. set, never runs `update()` on the live table (hybriel#113; it rebuilds a fresh episodes table
  128. and swaps the files over, the same rule `tools/migrate.hl` follows). Not run against the live
  129. data yet — the architect runs it (stop the container first, `storage/mpackdb` is not shared
  130. across processes, hybriel#21).
  131. ## What it does (step 5)
  132. `/unwatched` (`components/unwatched.hl`): every episode of a show the signed-in user follows
  133. (`follows.hl`, new) that is already released (`release` ≤ today) and not yet watched
  134. (`watches.hl` `isWatched`), newest release first. Creator: "/unwatched lists all unwatched
  135. episodes of shows followed in release date desc order"; "check icon is a disc with a check in
  136. it" — the SAME icon/class (`watches.hl` `watchClassOf`, moved there from `components/show.hl` so
  137. both pages share one definition instead of each declaring its own) and the same
  138. `setWatched`/`isWatched` watch logic as the show page (step 4), not copied.
  139. * Each row: check icon, the show's title (linking to `/shows/:slug`), "Ep `<number>` ·
  140. `<title>`", the release date.
  141. * A row only ever shows an unwatched episode, so a click always marks it watched (never toggles
  142. back, unlike the show page's checks) — the row leaves the list. Signed out: no rows, a
  143. "Sign in to see the shows you follow." message instead.
  144. * `follows.hl` (new): read-only per-user follow data (`followedShowIds`), kept apart from
  145. `shows.hl`/`watches.hl` the same way, for `/schedule` (ticket #6) to reuse.
  146. ## What it does (step 6)
  147. `/schedule` (`components/schedule.hl`): every episode of a show the signed-in user follows
  148. (`follows.hl`) that is NOT yet released (`release` > today), soonest first. Creator: "/schedule
  149. has a similar list without check icons for the upcoming episodes of shows followed" — same row
  150. layout as `/unwatched` (show title linking to `/shows/:slug`, "Ep `<number>` · `<title>`", the
  151. release date), minus the check icon and minus any click handling: the page is read-only, so it
  152. needs no `on server` face at all. Signed out: no rows, the same "Sign in to see the shows you
  153. follow." message as `/unwatched`.
  154. ## What it does (step 7)
  155. `/my/shows` (`components/myshows.hl`): every show the signed-in user follows, ordered by the
  156. follow's `at` (epoch ms), newest first. Creator: "trackers /my/shows that just ists the shows in
  157. desc order i followed them, small poster, title, last episode like s08e35". Read-only, no `on
  158. server` face (like `/schedule`).
  159. * Each row: a small poster (2.5rem wide, the same `/posters/<name>` route + placeholder as the show
  160. page; a show with no poster name at all gets `/posters/none`, i.e. the route's placeholder), the
  161. title linking to `/shows/<slug>`, and the **last watched episode** as `SxxEyy` (zero-padded to
  162. two digits) = the HIGHEST season/episode number the user has an episode watch for (architect's
  163. reading, in the ticket) — not the most recently watched one; season watches alone don't count.
  164. Nothing watched → no episode element at all.
  165. * Signed out: no rows, the same "Sign in to see the shows you follow." as `/unwatched`.
  166. * Cost: touches only the user's follows + episode watches (one `episodeById` fetch per watch),
  167. never all episodes of a followed show — 128 rows in ~0.2 s on a copy of the real data (STATUS.md).
  168. * Reuse: `follows.hl` `followsOfUser` (new — the whole follow record, `followedShowIds` now
  169. builds on it), `shows.hl` `episodeById` (new, one line), `showById`, `posterName`, `watches.hl`
  170. `watchesOfUser`.
  171. ## What it does (step 8)
  172. * Routes (`project.hl`): `/my/unwatched`, `/my/schedule`; the old `/unwatched`, `/schedule` are
  173. function routes answering **301** to them (`movedTo`, no query carried — the pages take none).
  174. * `shows.hl` (shared by all lists): `episodeCode(s, e)` → `S01E01` (`/my/shows`, `/my/unwatched`,
  175. `/my/schedule` rows: `S01E02 · <title>`), `titleWithYear(show)` → `Doctor Who (2005)` (`year`,
  176. else the first 4 chars of `release`, else the bare title — 197 shows have no `year`), `pad2`,
  177. `posterUrlOf(show)`. The show page's own `h1` stays without the year (it is not a list).
  178. * Show page: season count `1 episode` / `N episodes`.
  179. * **Speed** (the creator's real data: 128 follows, 11,692 watches, 831 unwatched rows):
  180. * `/unwatched` was ~15 s: `isWatched` scanned all 11,692 watches per episode. Now
  181. `watches.hl` `watchedSet(rows)` builds `'<type>:<id>' → true` ONCE; `/my/unwatched` and the
  182. show page's `buildRows` look up that map. Now ~0.6 s server render (the rest is fetching
  183. every episode of 128 shows; the insertion sort is ~50 ms).
  184. * `/my/shows` "25 s full load while data takes 0.2 s": (1) **hl:web serves one request at a
  185. time** — measured: a `/my/shows` request made during a 15 s `/unwatched` render waited 13.7 s;
  186. every poster request of every visitor queues the same way. Fixing `/unwatched` removes that.
  187. (2) 128 rows = 128 different `/posters/<oldId>.jpg` URLs, all answering the same placeholder
  188. (no poster files exist yet) — 128 requests per load. `posterUrlOf` now gives rows without a
  189. poster FILE the one URL `/posters/none` → 1 request, cached.
  190. * How it was measured: see STATUS.md ticket #8 ("Real-data check").
  191. ## What it does (step 9)
  192. `tmdbsync.hl` (`syncShow`), run by the app every day and by `tools/sync-tmdb.hl`:
  193. * **Which shows**: every show ANY user follows (`follows.hl` `allFollowedShowIds`, 126 on the real data).
  194. * **Per show** (1 + ⌈seasons/20⌉ requests + the poster if new): `GET /tv/<tmdbId>?append_to_response=external_ids`
  195. (status, counts, overview, `poster_path`, the season list, since #12 the external ids), then `GET /tv/<tmdbId>?append_to_response=season/1,…` (≤ 20
  196. seasons with their episodes per request). Bearer `TMDB_READ_TOKEN`.
  197. * **Matching, never duplicating, never deleting**: episode by `tmdbId`, else (161 migrated episodes have
  198. none) by S/E number if that one has no tmdbId yet — then it gets the tmdbId; season by (show,
  199. seasonNumber) (migrated seasons have no tmdbId). Existing episodes/seasons: title, summary, release
  200. updated; an empty TMDB value never overwrites. New ones: self-assigned 16-hex ids (NOT mpackdb's own —
  201. hybriel#113), `oldId = 'tmdb-episode:<id>'` / `'tmdb-season:<showTmdb>:<n>'` (the tables' `!oldId`
  202. index), linked into `season.episodes` / `show.seasons`. Show: `status`, `seasonsCount`,
  203. `episodesCount`, `tmdbSummary`, `tmdbPoster`, `tmdbSync` (ms). Season 0 (specials) only for a show that
  204. already has one; a TMDB season with no episodes is not created.
  205. * **Posters**: TMDB `poster_path`, size w342 → `storage/mpackdb/posters/<oldId>.<ext>` (the folder the
  206. existing `/posters/:name` route already served; inside `storage/` → in the nightly backup), `show.image`
  207. set to the ext. Downloaded again only when the file is missing or TMDB's `poster_path` changed.
  208. * **Daily run inside the app** (`project.hl` `syncTick`): an hl:time `every(1)` clock; at
  209. `TRACKER_SYNC_HOUR` (UTC), once per day, it queues all followed shows and then syncs ONE show per tick.
  210. hl:web serves one request at a time and `fetch()` blocks the whole process, so a step blocks requests
  211. for its duration (real data: ~0.7 s average, Saturday Night Live 53 seasons ~1.7 s; logged as
  212. `tmdb sync: slow step …` above 1.5 s) — requests queued meanwhile are served between two ticks; a tick
  213. never runs inside a page render. After a show with n requests the next waits n × 260 ms (TMDB ≤ 40
  214. requests / 10 s). Log: `tmdb sync: daily at 4:00 UTC`, `tmdb sync: start, N followed shows`,
  215. `tmdb sync done: shows=… newSeasons=… newEpisodes=… updatedEpisodes=… updatedSeasons=… posters=…
  216. requests=… errors=… seconds=…` (`docker logs tracker.worldapi.org | grep tmdb`). Tables are persisted
  217. every 10 shows and at the end (~36 ms).
  218. * **The tool** (same sync, sequential, paced; prints one line per show + the totals). **App stopped or a
  219. COPY only** — hl:mpackdb is one process per storage (hybriel#21):
  220. ```bash
  221. set -a; . ./.env; set +a # TMDB_READ_TOKEN, never print it
  222. TRACKER_STORAGE=$PWD/.scratch/realdata/storage/mpackdb ./bin/hybriel tools/sync-tmdb.hl [--limit N]
  223. ```
  224. * Real data (copy, 2026-09-30): first run 126 shows → +20 seasons, +800 episodes, 2651 episodes
  225. updated, 125 posters (6.4 MB), 380 requests, 0 errors, 186 s; a second run: all 0, 255 requests, 90 s.
  226. Details: STATUS.md ticket #9.
  227. ## What it does (step 10)
  228. The installable app, the same way calendar.worldapi.org does it (its ticket #3) — hl:web's own manifest
  229. and service worker from settings in `project.hl`, no JavaScript of ours:
  230. * `appIcons` (192 + 512 PNG, each `any` and `maskable`), `appTouchIcon` (180 PNG), `appFavicon`
  231. (`/icons/favicon.svg`), `appThemeColor` = token `darker` (the header, rgb(15, 20, 25)),
  232. `appBackgroundColor` = token `dark` (rgb(25, 30, 35)); name = `appTitle` "tracker". hl:web serves
  233. `/__hl/manifest.webmanifest` (start_url/scope `/`, display standalone) and links it, the
  234. apple-touch-icon and theme-color from every head. `/favicon.ico` is a real icon (16/32/48) for
  235. browsers that ask for it themselves. Each icon has its own `file` route in `project.hl`.
  236. * **Icons** (`icons/`): `icon.svg` is the source (512, hand-written: a TV with antennas and a check on
  237. the screen, `#4ec9b0` on rgb(25,30,35); everything inside the maskable safe zone, a circle of radius
  238. 204, so one image serves `any` and `maskable`); `favicon.svg` is the same drawing cropped tight on a
  239. rounded tile. Rendered on Loreana:
  240. ```bash
  241. rsvg-convert -w 192 -h 192 icons/icon.svg -o icons/icon-192.png
  242. rsvg-convert -w 512 -h 512 icons/icon.svg -o icons/icon-512.png
  243. rsvg-convert -w 180 -h 180 icons/icon.svg -o icons/apple-touch-icon.png
  244. for s in 16 32 48; do rsvg-convert -w $s -h $s icons/favicon.svg -o /tmp/fav-$s.png; done
  245. magick /tmp/fav-16.png /tmp/fav-32.png /tmp/fav-48.png icons/favicon.ico
  246. ```
  247. * **Offline**: `offline = [ Home ]` — the worker precaches the shell (runtime, modules, CSS, manifest,
  248. icons, favicon) and the document of `/`. Navigations are network-first; without a network `/` comes
  249. from the cache and every other page gets hl:web's own "Unavailable offline" page (503) — data pages
  250. need the network, no offline data. The shell (`components/main.hl`) shows "You are offline. Your shows
  251. and lists need the network." while `navigator.onLine` is false: hl:web gives a page no connection
  252. state and no mount hook, so an invisible `netProbe` runs an endless 1 s CSS animation (`styles.hl`
  253. `@keyframes tracker-net-tick`) and its `animationiteration` handler reads `navigator.onLine` (a write
  254. only when it changes). A server that is down while the device is online shows no note.
  255. ## What it does (step 12)
  256. * **Link icons** (`components/show.hl`, `shows.hl` `externalLinksOf`, `styles.hl` `showLinks`/`a.extlink`): under the
  257. title (phone: between the title row and the full-width poster; desktop: under the title, right of the poster) one
  258. small monochrome badge per id the show HAS — `TMDB` https://www.themoviedb.org/tv/<tmdbId> (a movie: `/movie/<id>`),
  259. `IMDb` https://www.imdb.com/title/<imdbId>/, `TVDB` https://thetvdb.com/dereferrer/series/<tvdbId>, `TVmaze`
  260. https://www.tvmaze.com/shows/<tvmzId>. A movie (`type = 'movie'`) shows only TMDB + IMDb. Each `target="_blank"
  261. rel="noopener"`. CSS only (muted grey badge, dark text, accent on hover), no copied logos. URL forms checked
  262. 2026-10-01: all four 301 to the site's slug page (TheTVDB's old `?tab=series&id=` too; `dereferrer` is its id form).
  263. * **Sync** (`tmdbsync.hl` `externalIdsFor`): the details request carries `append_to_response=external_ids` (no extra
  264. request); a MISSING `imdbId` (`tt` + digits) / `tvdbId` (number > 0) is filled from it. A show still without
  265. `tvmzId` → `GET <TVMAZE_BASE_URL>/lookup/shows?imdb=tt…`, on 404 `?thetvdb=<id>` (hl:fetch follows TVmaze's 301 to
  266. `/shows/<id>`); 550 ms pause per lookup (TVmaze 20 / 10 s) — both runners wait `pauseMsAfter(r)`. **An id the show
  267. has is never overwritten.** Totals gain `ids=` (ids filled) and `tvmazeRequests=`.
  268. * **Movies** (7814 of 9453 migrated rows are `type = 'movie'`, 1 followed: Star Trek: First Contact): before #12 the
  269. sync asked `/tv/<tmdbId>` for them — a DIFFERENT title on TMDB. Now a movie gets one `GET
  270. /movie/<tmdbId>?append_to_response=external_ids`: missing imdbId + its poster; no seasons/status/overview.
  271. * **Counting ids** (a COPY only): `TRACKER_STORAGE=$PWD/.scratch/realdata/storage/mpackdb ./bin/hybriel
  272. tools/count-external-ids.hl` → per type, all / followed: shows, tmdbId, imdbId, tvdbId, tvmzId.
  273. ## What it does (step 15)
  274. * **TVmaze merge** (`tmdbsync.hl` `mergeTvmaze`, after the TMDB step of `syncShow`, both runners): a series with a
  275. `tvmzId` → `GET <TVMAZE_BASE_URL>/shows/<tvmzId>/episodes` (1 request, 550 ms pause like the lookups). Matched by
  276. season/episode number: a missing episode is ADDED (`tmdbId = null`, `tvmzId`, `oldId 'tvmaze-episode:<id>'`; its
  277. season too if missing: `oldId 'tvmaze-season:<tvmzId>:<n>'`), an existing one gets an EMPTY title / air date
  278. filled. **A TMDB value is never overwritten.** Placeholder titles (`Episode 4`, `Épisode 4`, `Folge 4`, `TBA`,
  279. `isPlaceholderTitle`) count as empty — and TMDB's placeholder no longer overwrites a real title (else they flip
  280. daily). When TMDB lists the episode later, the TMDB step adopts the row by number (it has no tmdbId), TMDB wins.
  281. Skipped: TVmaze specials (no number), season 0 unless the show has one, summaries (TVmaze's are HTML).
  282. * **Numbering check** (`numberingAgrees`): ≥ 80 % of the episodes both know must air within a day OR share the title,
  283. and a show with episodes must share at least one S/E with TVmaze — else TVmaze is ignored for that show
  284. (`tvmazeSkipped`). Real data: The Daily Show (TVmaze seasons by year: would have added 1093 episodes), Star Trek:
  285. Prodigy / Blood & Treasure (TMDB merges double episodes → shifted), Alex Rider / Intergalactic / The Rising (other
  286. premiere dates, placeholder titles) are skipped.
  287. * Totals/tool line gain `tvmazeEpisodes= tvmazeSeasons= tvmazeFilled= tvmazeSkipped=`; `tvmazeRequests` now also
  288. counts the episode lists (≈ 125/day on real data, ~70 s more per daily run).
  289. ## What it does (ticket #14: search)
  290. Creator: "in the actionbar a search symbol that first checks our database and then has a fetch from web button that
  291. actually searches tmdb then for things we dont have yet". `components/search.hl` (page), `search.hl` (index, TMDB, import).
  292. * **Header**: a magnifier (`#searchlink`, SVG) before the ident selector → `/search` (client-side navigation).
  293. * **Address `/search/<text>`, not `/search?q=`**: hl:web gives a page its route params but no query string. A full load gets
  294. the segment already decoded by the server (an encoded `/` would split it → 404), a client-side navigation the raw one —
  295. `queryOfParam` decodes what is left, safely (malformed `%` → literal). While typing the address follows the text
  296. (`history.replaceState`; `/ ? #` become spaces). `/search?q=x` just opens the empty search page.
  297. * **Our database, as you type** (face `searchDb`, no session): `search.hl` builds an **in-memory index once at boot**
  298. (`project.hl` → `ensureIndex`, ~0.6 s on the real data, log line `search index: 9453 titles, 15157 people, 4180 keys,
  299. … ms`): every title/name → words (lower case, apostrophes dropped, punctuation = word break, Latin accents folded; a
  300. 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
  301. 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.
  302. Order: whole name, name starts with the text, first word does, rest; then shorter name; then series before movie, newer
  303. first. ≤ 30 titles ("The first 30 of N titles — type more…") + ≤ 10 people. Under 2 letters: "Type at least 2 letters.".
  304. People link to `/person/<slug>` (no person page yet — same as the show page's cast).
  305. * **Fetch from web** (face `searchWeb`, signed out too): ONE `GET <TMDB>/search/multi?query=…&include_adult=false&page=1`;
  306. people dropped, titles we have (same TMDB id AND kind: `series:<id>` / `movie:<id>`) left out. Poster thumbs straight
  307. from TMDB's image host (`w92`).
  308. * **Add** (face `searchImport`, session): signed out → the sign-in modal, nothing sent. Signed in → `importTitle`: one
  309. `GET /tv/<id>` (or `/movie/<id>`) for the new record (title, overview, first air/release date → year, status, genres
  310. matched to OUR genres by name, language, homepage, tagline; own 16-hex id, `oldId = tmdb-tv-<id>` / `tmdb-movie-<id>`,
  311. `urlSegment` like the migrated ones: `Pluribus`, taken → `-2`…), then **`tmdbsync.hl` `syncShow`** — seasons, episodes,
  312. poster, external ids exactly like the daily sync — `persistSync`, index updated, the public lists too (`catalog.hl`
  313. `refreshCatalogShow`, since the #13/#14 merge); then the show page opens. A title we
  314. already have answers its slug. A failure (TMDB 404 …) is shown on the page ("Could not add it: …"), the lists stay.
  315. Log: `search import: tv 225171 "Pluribus" → /shows/Pluribus (newSeasons=1 newEpisodes=9 posters=1 ids=3 requests=4 errors=0)`.
  316. * An imported title is NOT followed automatically, so the daily sync (followed shows only) does not update it later.
  317. * Hybriel traps (hybriel#121 / #122): no list is ever appended to (replaced whole); no member reads both the route param
  318. `q` and `session`; the gate checks the lists after a failed import (a session face that stays on the page).
  319. ## What it does (mission 047: checks after client-side navigation, mobile first, show page)
  320. * **Invisible checks fixed** by re-vendoring Hybriel master ff51cf46 (see "Vendored Hybriel"): the old
  321. build created SVG built in the BROWSER (client-side navigation, expand/collapse, a check click) in the
  322. XHTML namespace — nothing drawn. Server-rendered pages were fine.
  323. * **Mobile first** (`styles.hl`): every rule outside the one `@media (min-width: 40rem)` block is the
  324. phone's (390 px); the block only adds the wide layout. The header is one row on a phone (brand, ident
  325. selector, Log out); `/my/unwatched` + `/my/schedule` rows are two lines on a phone (show + date, the
  326. episode below), one line from 40rem.
  327. * **Show page** (`components/show.hl`): phone = title (+ Follow) first, then the poster at the full
  328. content width, then genres/plot/cast (`showMeta` is `display: contents` there, the title row
  329. `order: -1`); desktop unchanged (poster 10rem left). Season header: a caret at the right edge (down =
  330. open, `season-row.collapsed` turns it right). Episode rows are not indented (episode check under the
  331. season check). Artists orange (`#ce9178`, token `orange`). Unwatched check = the inverted solid icon:
  332. accent outline circle + accent check mark; watched = filled accent disc, dark check.
  333. * **Follow toggle** (signed in): "Follow" = the inverted button (accent border), "Following" = filled;
  334. face `showToggleFollow` → `follows.hl` `setFollowed` (new record, `at` = now → top of `/my/shows`;
  335. unfollow deletes the user's records for that show). **Signed out** the checks and Follow are visible;
  336. a click opens the modal "You need to sign in to follow shows and mark episodes." (Close + "Sign in with
  337. ident" = the ident login link) and sends nothing. The modal is `components/modal.hl`, copied verbatim
  338. from components.hybriel.worldapi.org `components/modal/modal.hl` (becbd59, 2026-09-30) — its own
  339. `Style` lands in `/__hl/app.css`.
  340. ## What it does (step 13)
  341. * **`/`** (`components/home.hl`): signed in, four icon tiles (inline SVG, accent) → `/my/unwatched`, `/my/schedule`,
  342. `/my/shows`, `/my/movies` (2×2 on a phone, 4 in a row from 40rem). For everyone: the text about the site (signed out
  343. plus a "Log in with ident" hint), then **Movies** (→ `/movies`) = the 10 latest RELEASED movies (release ≤ today) and
  344. **Shows** (→ `/shows`) = the 10 series whose newest released episode is newest. Tiles: poster (`posterUrlOf`:
  345. the file or `/posters/none`), title, year; a link to `/shows/<slug>` (movies too — same page, same Follow button).
  346. Phone: each row scrolls sideways inside itself (`tile-row`, the page never does); from 40rem a grid of 5.
  347. * **`/movies`, `/shows`** (`components/movies.hl`, `components/allshows.hl`, both compose `components/tilelist.hl`):
  348. every movie by `release` desc (future-dated ones first, undated last — "all … sorted by release date desc"); every
  349. series by its newest released episode (series with none released after them, by their own `release`). 24 per page,
  350. grid of 3 (phone) / 6. **The page is a path segment: `/movies/page/2`**, page 1 = `/movies`; out of range → the last
  351. page, not a number → page 1. NOT `?page=2` (the mission asked for it): a page component cannot read the query
  352. (hybriel#11, query half open) and hl:web's Back (`popstate`) drops the query — every page change would be a full
  353. reload and Back would land on page 1. With the path, paging is a client-side navigation and Back works.
  354. * **Pagination** = components.hybriel's `components/pagination/pagination.hl`, **copied verbatim** to
  355. `components/pagination.hl` (4b4dabe) — re-copy, don't edit. It has no data out (its README, hybriel#87), so
  356. `tilelist.hl` wraps it in `pager-box { on click }`: a click on one of its buttons bubbles there, the button's text
  357. ("Previous", "Next", a number; "…" nothing) gives the page → `navigate()`. Hidden when there is only one page.
  358. `styles.hl` makes its buttons a bit tighter on a phone so "Previous 1 … 99 100 101 … 326 Next" stays one row.
  359. * **`/my/movies`** (`components/mymovies.hl`): the movies the user follows, newest follow first (same rows as
  360. `/my/shows`: small poster, title with year). `/my/shows` now leaves movies out. Shared sort: `follows.hl`
  361. `insertByFollowDesc`.
  362. * **Speed — `catalog.hl`**: built ONCE at start (one scan of shows + episodes: real data ~1 s, server answers
  363. ~1.5 s after launch) into two sorted id lists in memory (`static index`; hand-written merge sort). A page only
  364. slices 24 ids and fetches those shows: real data, server time ≈ 5 ms per page. Kept fresh without a rebuild:
  365. `project.hl` `syncTick` calls `refreshCatalogShow(id)` after each synced show (its seasons/episodes only, ~2.5 ms;
  366. 126 shows 0.3 s); a NEW DAY moves episodes that came out today (each series keeps its upcoming release dates) into
  367. place on the first read. `tools/sync-tmdb.hl` runs with the app stopped → the next start rebuilds.
  368. * **Content warning** (open, not decided here): the migrated movie table holds many adult titles (the old tracker
  369. imported TMDB lists); they show on the public `/` and `/movies`. No `adult` flag is stored.
  370. ## Test
  371. ```bash
  372. node tests/browser.mjs # THE GATE: this app's own server + its OWN ident (a copy of
  373. # ident's code without .env, codes to a mail sink — no live
  374. # ident, no real mail; tests/identkit.mjs, same as calendar's
  375. # gate) + a real headless Chrome: signed out (header shows the
  376. # selector + "Log in with ident"; since #13 the homepage has
  377. # content, see below) -> sign in -> header shows "Log out" -> sign
  378. # out -> signed out again -> a reload stays signed out. Also
  379. # checks the header has no bottom border and filled buttons
  380. # (incl. the ident selector's) have none, while inverted ones
  381. # ("Log out") keep theirs.
  382. # tracker.worldapi.org#4: a fixture show (tests/seed-show.hl,
  383. # written into the gate's own storage before the server starts)
  384. # proves /shows/:slug — header renders, no checks signed out;
  385. # signed in, click an episode check (turns solid), click a
  386. # season check (itself AND its episodes turn solid, proven
  387. # after a reload — server-side, not just client state).
  388. # tracker.worldapi.org#5: the same fixture (now followed by the
  389. # test user) proves /unwatched — signed out, no rows; signed in,
  390. # only the two already-released episodes show, newest first, the
  391. # far-future one excluded; click a check, the row disappears
  392. # (proven server-side after a reload).
  393. # tracker.worldapi.org#6: the same fixture proves /schedule —
  394. # signed out, no rows; signed in, only the far-future episode
  395. # shows (the two already-released ones excluded), no check icons
  396. # at all, unaffected by any watch toggle on the other pages.
  397. # tracker.worldapi.org#7: the fixture now has a 2nd followed
  398. # show (followed later, no poster name, never watched) and
  399. # proves /my/shows — signed out, the sign-in message; signed
  400. # in, newest follow first, title links, loaded posters (the
  401. # placeholder), S01E02 after /unwatched's click, then S02E01
  402. # (highest, not most recent) after the show-page checks; the
  403. # unwatched show shows no episode.
  404. # tracker.worldapi.org#8: the lists moved to /my/unwatched and
  405. # /my/schedule (old addresses: 301, and the browser lands on
  406. # the new one), rows say S01E02 / S02E01, titles carry the
  407. # year ("Gate Test Show (2024)", "Unwatched Gate Show (2025)"
  408. # — from `release`, that show has no `year`), the show page
  409. # says "1 episode" / "2 episodes", /my/shows gives the show
  410. # with a poster FILE (the gate writes posters/sh1.png) its
  411. # own URL and the other one /posters/none.
  412. # tracker.worldapi.org#9: a FAKE TMDB (tests/faketmdb.mjs, a
  413. # node http server inside the gate, fixed JSON + a png; the
  414. # app/tool get TMDB_BASE_URL/TMDB_IMAGE_URL + a gate token —
  415. # never the real TMDB). App stopped → tools/sync-tmdb.hl: +2
  416. # seasons, +4 episodes, 3 fixture episodes matched by S/E and
  417. # updated, 2 posters, no season 0, Bearer token on every call;
  418. # a 2nd run changes nothing. Restarted: /my/schedule and
  419. # /my/unwatched show the new episodes, /my/shows + the show
  420. # page the downloaded posters (/posters/sh2.png byte-equal),
  421. # S1 keeps its watched episodes, no duplicates. Then the
  422. # app's OWN daily run (TRACKER_SYNC_HOUR = the current UTC
  423. # hour, fake TMDB slowed to 700 ms/request, one new episode):
  424. # it runs, finds the episode, and pages asked meanwhile are
  425. # answered < 2.5 s (between shows) though the run takes ≥ 3 s.
  426. # tracker.worldapi.org#10: the PWA — head links (manifest,
  427. # apple-touch-icon, favicon, theme-color = token darker), the
  428. # manifest (type, name, start_url, scope, standalone, colours),
  429. # every icon a real PNG of its size, touch icon 180, favicon
  430. # svg + ico; Chrome: no installability error, manifest parsed
  431. # without errors; the worker /__hl/sw.js registered, scope /,
  432. # controls the page (a separate 390 px phone tab). OFFLINE:
  433. # CDP offline → the note appears; the server STOPPED too (CDP's
  434. # offline does not reach the worker's own fetches) → reload of
  435. # / shows header + note from the cache; /my/shows gives
  436. # "Unavailable offline"; server back → no note.
  437. # mission 047: a CLIENT-SIDE navigation /my/shows → the show
  438. # (window marker proves no reload): every check/caret svg in the
  439. # SVG namespace and drawn — again after expand, an episode click,
  440. # collapse (FAILED on the old vendored build: XHTML namespace,
  441. # circle width 0 — /tmp/w047/old-build-gate.log); caret down/
  442. # right and flips; episode check under the season check; artists
  443. # rgb(206,145,120); unwatched icon = accent outline + accent
  444. # check; Follow: "Following" filled → unfollow ("Follow",
  445. # outlined) → gone from /my/shows, survives reload → follow →
  446. # top of /my/shows; signed out: checks + Follow visible, a click
  447. # (episode, season, Follow) opens the sign-in modal, Close
  448. # closes it, a reload shows nothing was written. 390 px + 1280
  449. # px: header one row, no sideways scroll, show-page order
  450. # (phone: title, full-width poster, genres, plot, cast).
  451. # tracker.worldapi.org#12: link icons only for existing ids
  452. # (show 1: TMDB+IMDb, show 2: TMDB+TVDB, a fixture MOVIE: TMDB
  453. # /movie/), exact hrefs, target _blank + rel noopener, a click
  454. # opens a NEW tab (CDP target, canAccessOpener false) and the
  455. # page stays; also after client-side navigation; phone/desktop
  456. # position. Sync: external_ids come with the details request,
  457. # the movie only via /movie/, a FAKE TVmaze (same fake server,
  458. # /tvmaze/…, 301 like the real one): imdb hit, imdb 404 → thetvdb
  459. # hit; afterwards the stored ids are unchanged (tt0000001, 4002
  460. # — TMDB says otherwise) and the missing ones filled; 2nd run:
  461. # ids=0, no TVmaze request.
  462. # tracker.worldapi.org#13: fixture + 32 never-followed movies
  463. # (Pager Movie 01…30 = 2001-01-01…30, one 2099, one undated) and
  464. # a series with an episode 2021-01-01. Home signed out: no
  465. # icons, the text, headings → /movies /shows, the 10 latest
  466. # RELEASED movies (30…21, not the 2099 one), the series by
  467. # newest released episode; signed in: 4 icons → /my/…, the
  468. # Movies icon → /my/movies (client-side). /movies via the
  469. # heading: page 1 = 2099 + 30…08, "1 2 Next"; Next →
  470. # /movies/page/2 (client-side, 9 left, undated last), "1" →
  471. # /movies, Back → page 2, direct load, page 99 → 2, abc → 1.
  472. # /shows: series only, order, no pagination for one page; a
  473. # tile → show page. Follow a MOVIE on its page → /my/movies
  474. # has it, /my/shows does not. After the sync tool + restart
  475. # /shows reorders (S03E01); after the IN-APP run (TMDB adds a
  476. # released episode to show 2) /shows and the home row put
  477. # show 2 first without a restart. Offline / from the cache
  478. # still has the text and both rows.
  479. # Session-sync (hybriel 64527baa): after each face-driven change
  480. # (/my/unwatched click, expand, episode click, Follow) every row
  481. # appears exactly once.
  482. # tracker.worldapi.org#15: the fake TVmaze also serves
  483. # /tvmaze/shows/<id>/episodes. First runs: lists agree with
  484. # TMDB → nothing taken. Then TVmaze knows more: S3E4's title +
  485. # date filled (TMDB "Episode 4"), S3E5 + a new S4 added, TMDB's
  486. # S3E1 title kept, show 2 numbered differently → skipped;
  487. # idempotent; /my/schedule + show page show them; TMDB catches
  488. # up → adopts the rows by number (no duplicates, TMDB wins).
  489. # tracker.worldapi.org#14: the header magnifier → /search
  490. # (client-side); typing: 1 letter → "at least 2", "gate" →
  491. # 3 titles in order with year/type/link, address /search/gate;
  492. # people ("test act"); case/accents ("GATE tést"); nothing →
  493. # note + Fetch; reload of the address = same results; bad %
  494. # escapes → 200; "/" → space in the address. Fetch from web
  495. # (fake TMDB search/multi): ours (tv 1001, movie 1003) and the
  496. # person left out, w92 thumbs; signed out Add → modal, nothing
  497. # sent. Signed in: Add tv 1006 (details 404) → error, every
  498. # list unchanged (hybriel#121/#122); Add tv 1004 → its page
  499. # with season/episodes/poster/genre/4 id links, exact TMDB
  500. # calls; then in our results and gone from the web list; Add
  501. # movie 1005 → /movie/ only. Screenshots search-{390,1280},
  502. # search-web-{390,1280}, search-signed-out-modal-1280.
  503. # Merge #13 + #14: an imported title is on /movies and /shows
  504. # at once (search.hl → catalog.hl refreshCatalogShow); #13's
  505. # 32 extra fixture titles are 'Pager …' so 'gate' finds only #14's.
  506. # 167 checks (#13 + #14 + #15 + 1 merge check). Own servers :8700 (this app) / :8701 (ident copy)
  507. # / :8710 (fake TMDB); own storage .scratch/gate-store; Chrome
  508. # on 8702-8709. Other ports (workers get 8720-8739):
  509. # TRACKER_GATE_PORT=8720 TRACKER_GATE_IDENT_PORT=8721 TRACKER_GATE_CHROME=8722-8726 TRACKER_GATE_TMDB_PORT=8727 node tests/browser.mjs
  510. # TRACKER_GATE_SHOTS=/tmp/x also writes {home,show,my-shows,
  511. # my-unwatched,my-schedule,movies,movies-page-2,shows-list,
  512. # my-movies}-{390,1280}.png, signed-out-modal-
  513. # 1280.png, synced-*.png (after the sync, 1280×900; synced-show-390
  514. # + synced-movie-390 at 390) + pwa-phone-
  515. # online/offline.png (390 px, dpr 2) — LOOK at them
  516. ps -eo pid,args | grep [h]l-browser-tier # must print nothing afterwards
  517. ```
  518. ## Deploy (Byrodin)
  519. Target: `/CONTAINERS/projects/tracker.worldapi.org` on Byrodin, container
  520. `tracker.worldapi.org` (`docker-compose.yml`: debian:12-slim, host network,
  521. `HL_HOST=127.0.0.1`, `TRACKER_PORT=45008`, `TRACKER_WATCH=0`, the folder mounted at
  522. `/home/tracker`, `./bin/hybriel project.hl`), public https://tracker.worldapi.org/ via
  523. nginx (TLS ends there; no baseUrl/tls in the app, like ident/notes).
  524. * **First deploy: done by the architect** (folder, nginx vhost with WebSocket Upgrade
  525. headers, cert, DNS, **and registering this app in the live ident** — `/apps` → name +
  526. origin `https://tracker.worldapi.org` → the API key + secret go into `.env` next to
  527. `docker-compose.yml`, `TRACKER_KEY=… TRACKER_SECRET=…`).
  528. * **Later: `./deploy.sh`** on Loreana, in this folder: runs the gate (refuses on a failure;
  529. `--skip-tests` skips it LOUDLY), backs up `storage/`/`.sessions/`/`.env` (what exists) to
  530. Loreana's `/media/SLOW1TB2/deploy-backups/<app>/` (newest 5 kept; an empty/failed backup stops
  531. the deploy), then rsyncs the code to
  532. `[email protected]:/CONTAINERS/projects/tracker.worldapi.org` (never `storage/`,
  533. `.sessions/`, `.env`, `.scratch/`, `server.*`, logs — the preview is checked for them; no
  534. `--delete`), `docker compose up -d && docker compose restart` over `ssh -F /dev/null`,
  535. then waits for https://tracker.worldapi.org/ to answer 200.
  536. * `./deploy.sh --dry-run` = the gate + `rsync -n` + the commands it would run (no restart, no
  537. URL check). `--target DIR|HOST:DIR` and `--url URL` point it elsewhere (tested against a
  538. local directory, see STATUS.md).
  539. ## Data
  540. `storage/mpackdb/`: `users.db` (`identity` → this app's own user id, `users.hl`) plus, since
  541. step 2, the old tracker's data — `genres.db` (27), `persons.db` (15157), `shows.db` (9453),
  542. `seasons.db` (6430), `episodes.db` (231584), `follows.db` (128), `watches.db` (11692), all with
  543. fresh mpackdb ids and every reference re-pointed (`tools/migrate.hl`); the one old user is this
  544. app's user for ident short id `az5b2`. Since step 9 the TMDB sync adds seasons/episodes and
  545. `storage/mpackdb/posters/<oldId>.<ext>` (w342, ~50 KB each), and sets `tmdbSync`/`tmdbPoster` on
  546. shows. Known in the migrated data (not caused by the sync): 12 duplicate episode tmdbIds and 1
  547. episode whose `show`/`season` disagrees among the followed shows. Counts and the referential-integrity proof:
  548. `STATUS.md` "ticket #2". Real data 2026-10-01: 7,814 movies + 1,639 series (`shows.type`), 232,384 episodes (935
  549. series have a released one).
  550. ## Files
  551. | File | |
  552. |---|---|
  553. | `CONCEPT.md` | the creator's concept — do not edit |
  554. | `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), `/shows/:slug`, `/my/shows`, `/my/movies` (#13), `/my/unwatched`, `/my/schedule`, `/unwatched` + `/schedule` → 301, `/search/:q?` (#14), `/` component Home), the app's own session cookie; #14: builds the search index at boot (`ensureIndex`) |
  555. | `users.hl` | the ident exchange + the `usersTable`, copied from calendar.worldapi.org's `users.hl` |
  556. | `login.js` | bridge between `<ident-selector>`'s `ident-login` event and the shell, copied verbatim from calendar.worldapi.org |
  557. | `components/home.hl` | `/` (#13): signed in the 4 icon tiles; the text; the latest 10 movies and series (catalog.hl) |
  558. | `components/movies.hl`, `components/allshows.hl` | `/movies` (+ `/movies/page/:page`), `/shows` (+ `/shows/page/:page`): every movie / series, 24 per page (#13) |
  559. | `components/tilelist.hl` | the poster grid + pagination both lists compose; turns a pagination click into `navigate()` (#13) |
  560. | `components/pagination.hl` | the pagination, copied verbatim from components.hybriel.worldapi.org (`components/pagination/pagination.hl`, 4b4dabe) — re-copy, don't edit |
  561. | `components/mymovies.hl` | `/my/movies`: followed movies, newest follow first (#13) |
  562. | `catalog.hl` | the public lists: built once at start, refreshed per synced show and per new day (#13) |
  563. | `components/main.hl` | the shell (header: brand, `<ident-selector>`, log in / log out; the offline note + its `netProbe` tick, #10) |
  564. | `components/loginfailed.hl` | `/login/failed`: why a login didn't work |
  565. | `components/show.hl` | `/shows/:slug`: the show page (header, Follow toggle, seasons with caret, episodes, watch check; signed out the sign-in modal) |
  566. | `components/modal.hl` | the modal dialog, copied verbatim from components.hybriel.worldapi.org (`components/modal/modal.hl`, becbd59) — re-copy, don't edit |
  567. | `components/unwatched.hl` | `/my/unwatched`: unwatched, already-released episodes of followed shows, newest first |
  568. | `components/schedule.hl` | `/my/schedule`: not-yet-released episodes of followed shows, soonest first, no checks |
  569. | `components/myshows.hl` | `/my/shows`: followed SERIES (#13: movies → `/my/movies`), newest follow first — small poster, title, last watched `SxxEyy` |
  570. | `shows.hl` | read-only data access (shows/seasons/episodes/genres/persons), plus the list labels `episodeCode`/`titleWithYear`/`posterUrlOf` (#8), reused by the `/my/` pages; `externalLinksOf` (#12: the link icons) |
  571. | `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 047: the show page's Follow toggle) |
  572. | `tmdbsync.hl` | the TMDB sync of one show (`syncShow`: seasons, episodes, poster; #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) |
  573. | `watches.hl` | per-user watch state (episode/season checks, the shared check-icon class `watchClassOf`, `watchedSet` = the user's watches as a map, built once per page), reused by `/unwatched`/`/schedule`/`/my/shows` |
  574. | `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)") |
  575. | `styles.hl` | all CSS (imports the tokens from `shared/tokens.hl`; accent green `#4ec9b0`) |
  576. | `shared/tokens.hl` | the WorldAPI tokens, vendored verbatim from `ident.worldapi.org/shared/tokens.hl` |
  577. | `tests/browser.mjs` | the gate (above) |
  578. | `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 |
  579. | `tests/identkit.mjs` | starts a throwaway ident copy for the gate, copied from calendar.worldapi.org's `tests/` |
  580. | `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` |
  581. | `tests/realdata-check.mjs` | NOT the gate: the real-data browser check on a COPY of live `storage/` (STATUS.md, mission 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) |
  582. | `search.hl` | #14: the search — in-memory index (`ensureIndex`, `dbSearch`), `queryOfParam`, TMDB `webSearch`, `importTitle` (new record + `syncShow`) |
  583. | `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) |
  584. | `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 |
  585. | `tools/count-external-ids.hl` | #12: how many shows have each external id, per type, all / followed — ONLY on a copy |
  586. | `tools/count-duplicate-ids.hl` | duplicate `@id`s per table — ONLY on a copy (hybriel#113 check, mission 047) |
  587. | `tests/cdp.mjs`, `tests/ports.mjs` | the CDP browser driver, copied from `ident.worldapi.org/tests/` |
  588. | `docker-compose.yml`, `Dockerfile`, `deploy.sh` | Byrodin container; the deploy from Loreana (section "Deploy") |
  589. | `tools/migrate.hl` | one-off: old MongoDB export → `storage/mpackdb/` (step 2, idempotent, see `STATUS.md`) |
  590. | `tools/verify.hl` | one-off: proves the migrated data against a COPY of `storage/mpackdb/` (counts, zero dangling references, one show end to end) |
  591. | `tools/sync-tmdb.hl` | the TMDB sync from the command line (#9) — app stopped or a copy; `--limit N`; prints counts |
  592. | `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)" |
  593. ## Vendored Hybriel
  594. `bin/hybriel` + `plugins/` = **hybriel master ff51cf46** (2026-10-01 00:27, mission 047; includes
  595. #113 b08384e8, #115/#116 428bc56c/6b721937, #118 f5d709a4, the client-built SVG fix, hl:markdown),
  596. `bin/hybriel` sha256 `55a724a0cf8680247616a8b66b792cc0ee3ce797afe80d764347ee242d6facc5`. No local
  597. patch. Built read-only from an archive (never in the hybriel checkout):
  598. ```bash
  599. git -C /media/STORAGE/projects/hybriel archive master | tar x -C .scratch/hybriel-047
  600. 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)
  601. cp zig-out/bin/hybriel ../../../bin/hybriel
  602. # plugins: copy every plugin dir the app already has (core crypto data fetch fs http http1 mpackdb proc smtp
  603. # time web) from .scratch/hybriel-047/plugins/ over plugins/ — `diff -rq plugins .scratch/hybriel-047/plugins`
  604. # must then list only plugins the app does not use
  605. ```
  606. The previous build (837fe120 via ident, sha256 `9e5e95b3…`) is kept in `.scratch/pre-047/` (bin/ +
  607. plugins/). After a re-vendor run the gate.

Branches

Latest commits

  • dcc5eecaMerge branch 't14-search'mre
  • 03edc783Merge branch 't15-tvmaze'mre
  • 71b46345tracker#15: numbering check by date or title, placeholder titles in other languages, docs + real-data proofmre
  • 6bb2daf1tracker#13: homepage (tiles, intro, latest movies/shows), /shows, /movies/page/N, /my/movies; lists cached in memorymre
  • b8bd1157tracker#14: README + STATUS (search, real-data numbers, gate, merge notes)mre
  • 65c694a8tracker#14: search — header magnifier, /search/<text> (in-memory word-prefix index over titles + people), Fetch from web (TMDB search/multi, ours left out), Add = import via syncShow; gate +25 checks, real-data scriptmre
  • 34f2c15btracker#15: TVmaze merge in the sync (gaps only: new episodes/seasons, empty titles/air dates; numbering check), fake TVmaze episodes + gatemre
  • cbdc4ea7tracker#12: link icons TMDB/IMDb/TVDB/TVmaze; sync fills missing ids (TVmaze lookup); movies fetched via /movie/mre
  • b105bcd8tracker#11: Hybriel master ff51cf46 (checks no longer vanish), mobile-first styles, carets, follow button, sign-in modal, inverted check, orange castmre
  • 31b758aatracker#10: installable app (manifest, service worker, offline shell), own icon + faviconmre
  • 2fa9d997tracker#9: TMDB sync (followed shows: seasons, episodes, posters), tools/sync-tmdb.hl + daily run 04:00 UTC, fake TMDB in gatemre
  • 49e1f61edeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 54070a4etracker#8: /my/unwatched + /my/schedule (301 from old), S01E01, title (year), 1 episode, watched-set lookup (unwatched 15s -> 1s)mre
  • 3251488atracker#7: /my/shows (followed shows, newest follow first, poster, title, last watched SxxEyy); gate can take screenshots (TRACKER_GATE_SHOTS)mre
  • 44b7d9f9tracker#6: /schedule — upcoming episodes of followed shows, soonest firstmre
  • 91c9fc8ctracker#5: /unwatched — unwatched released episodes of followed shows, newest firstmre
  • a97c0295tracker#4: show page /shows/:slug (header, seasons, episodes, watch checks) + tools/relink-episode-seasons.hlmre
  • 05f407c5tracker: no border on any button except inverted ones (Log out, ident status and identities too); header brand weight 100mre
  • 17375427tracker#2: tools/migrate.hl + tools/verify.hl — old MongoDB data into mpackdb with new idsmre
  • 31aac936tracker#3: no border on the header and on filled buttons; inverted buttons keep theirsmre