tracker
All repositories: gitoria
93.9 KB
# tracker.worldapi.orgThe new tracker: a Hybriel app that will track the TV shows (and later movies) the creatorfollows and watches. **`CONCEPT.md` (the creator's) is the source of truth** — read it first;nothing is built that it does not describe. Built step by step, one ticket per step(tracker.worldapi.org#1, #2, …).**Built so far — step 1 (ticket #1)**: the shell. The header carries the ident login (anidentity selector, like calendar/gitoria) and sign-out; the homepage itself stays empty. Nodesign carried over from the old app.**Step 2 (ticket #2)**: the old tracker's MongoDB export is now in `storage/mpackdb/` (newmpackdb ids, every reference re-pointed) — see "Data" and `STATUS.md`. Nothing is shown in theUI yet; the homepage still renders empty. That is a later step, from the creator.**Step 4 (ticket #4)**: the show page, `/shows/:slug` (`components/show.hl`) — poster, title,genre pills, plot, cast (as described in "What it does (step 4)" below), every season with itsepisodes, and the watch check (episode and season, season bulk-toggles its episodes).**Step 5 (ticket #5)**: `/unwatched` (`components/unwatched.hl`) — every already-released,unwatched episode of a show the signed-in user follows, newest release first (see "What it does(step 5)" below).**Step 6 (ticket #6)**: `/schedule` (`components/schedule.hl`) — every not-yet-released episodeof a show the signed-in user follows, soonest first, no check icons (see "What it does (step 6)"below).**Step 7 (ticket #7)**: `/my/shows` (`components/myshows.hl`) — every show the signed-in userfollows, newest follow first: small poster, title, last watched episode (`S08E35`) (see "What itdoes (step 7)" below). The homepage itself is still empty.**Step 8 (ticket #8)**: the personal lists all live under `/my/`: `/my/shows`, `/my/unwatched`,`/my/schedule` (old `/unwatched`, `/schedule` answer 301). Episodes everywhere as `S01E01`, showtitles in lists with the year (`Doctor Who (2005)`), "1 episode" singular, and every `/my/` pageunder 1 s full load on the real data (see "What it does (step 8)" below).**Step 9 (ticket #9)**: the TMDB sync — every show someone follows gets its new seasons/episodes(existing ones updated: title, summary, release) and its poster from TMDB, once a day inside the app(04:00 UTC) and on demand with `tools/sync-tmdb.hl` (see "What it does (step 9)" below).**Step 10 (ticket #10)**: an installable app (PWA) with its own icon — a TV with a check mark, tracker greenon dark; also the favicon. The shell (`/`) opens without network and says it is offline (see "What it does(step 10)" below).**Step 12 (ticket #12)**: the show page's header has small link icons to the show on TMDB, IMDb, TheTVDB andTVmaze (a movie: TMDB + IMDb), only for the ids the show has, each in a new tab; the TMDB sync fills missingids (TMDB `external_ids`, TVmaze lookup) and never overwrites one (see "What it does (step 12)" below).**Step 13 (ticket #13)**: the homepage has content — signed in, four big icons (Unwatched, Schedule, Shows,Movies → the `/my/` pages); for everyone a short text about the site, the 10 latest released movies and the 10series with the newest released episode (headings link to `/movies` and `/shows`: everything, 24 per page). Plus`/my/movies` (the movies you follow); `/my/shows` is series only now (see "What it does (step 13)" below).**Step 15 (ticket #15)**: the daily sync also asks TVmaze for every series and adds what TVmaze has but TMDB notyet — new episodes/seasons, missing titles and air dates; TMDB values are never overwritten (see "What it does(step 15)" below).**Step 14 (ticket #14)**: the search — a magnifier in the header opens `/search`; typing searches OUR shows, movies andpeople at once (in-memory index), "Fetch from web" lists TMDB titles we don't have yet, "Add" imports one like the dailysync and opens its page (signed out: the sign-in modal) (see "What it does (ticket #14)" below).**Mission 054**: adult titles never appear in the public lists (homepage rows, `/movies`, `/shows`) or the search (ours and"Fetch from web") — only titles TMDB says are NOT adult are listed, unknown counts as hidden. A followed title stays on theuser's `/my/` pages and its page. A background job in the app (the "adult backfill") asks TMDB once per title for theadult flag, the poster and the external ids (see "What it does (mission 054)" below).**Step 16 (ticket #16)**: person pages — `/person/<slug>` (the show page's cast and the search's people link there): name,photo, born/died, a short bio, the filmography (poster, title, year, role, newest first). A person we don't have complete yet isfetched from TMDB on the first visit (person + all credits, every missing title added), the page fills in without a reload(see "What it does (ticket #16)" below).**Ticket #26**: titles a person page adds from a filmography (#16: minimal records) are completed — opening one shows skeletons andfills in poster, links, cast, seasons/episodes without a reload; a background job (the "details repair") completes the rest (see"What it does (ticket #26)" below).**Ticket #28**: a title page shows its WHOLE cast (a series: every season's, main cast first by episode count, then "Guest stars")with the characters, collapsed after 20 with "Show all (N)", and its key crew — Created by, Directed by, Written by, Screenplay,Story, Music — every name linked to its person page. Stored by the daily sync, the search import and the completion of #26 from thedetails request they make anyway; every other title gets them once from a background job (the "credits job") (see "What it does(ticket #28)" below).**Ticket #18**: the daily sync asks TMDB's and TVmaze's CHANGE LISTS first (what changed since the last run) and syncs only thetitles of ours that changed — followed or not, so every title stays current; it refreshes the title's own record too (title,TMDB text, tagline, status, genres …). `summary` is the creator's own text and never written by the sync: the page shows`summary`, else TMDB's `tmdbSummary`, else TVmaze's `tvmazeSummary` (see "What it does (ticket #18)" below).Written in **Hybriel** on **hl:web**, same stack and conventions as `ident.worldapi.org`(vendored plugins/binary copied from there, see "Vendored Hybriel").## Run (dev, Loreana)```bashcd /media/STORAGE/projects/tracker.worldapi.orgTRACKER_PORT=8700 TRACKER_SYNC=0 setsid nohup ./bin/hybriel project.hl > server.log 2>&1 < /dev/null & echo $! > server.pid# TRACKER_SYNC=0: the runtime loads .env (real TMDB token) by itself — without it a dev server syncs from TMDB at 04:00 UTC# stop: kill $(cat server.pid)```* Config (env; the real environment outranks nothing here — there is no `.env` reader inproject.hl, unlike ident's SMTP settings — set these in the shell or `docker-compose.yml`):| Variable | Default | ||---|---|---|| `TRACKER_PORT` | 45008 | || `TRACKER_URL` | `http://127.0.0.1:<port>` | this app's own origin — the ident login button's `return=` is built from it || `IDENT_URL` | `https://ident.worldapi.org` | || `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 || `TRACKER_WATCH` | on | `0` = no dev watcher (the container) || `TRACKER_STORAGE` | `./storage/mpackdb` | the `usersTable` directory || `TRACKER_SESSIONS` | `./.sessions` (hl:web's own default) | this app's own session store || `HL_HOST` (or `HOST`) | 0.0.0.0 | interface to bind; `127.0.0.1` on Byrodin || `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` || `TRACKER_SYNC` | on | `0` = no daily TMDB run inside the app || `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` || `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 || `TVMAZE_BASE_URL` | `https://api.tvmaze.com` | #12: the TVmaze id lookup; the gate points it at its fake (`<fake>/tvmaze`) || `TRACKER_BACKFILL` | on | mission 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 || `TRACKER_BACKFILL_LOG_EVERY` | 500 | a progress line every N titles || `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) || `TRACKER_REPAIR_LOG_EVERY` | 100 | a progress line every N titles || `TRACKER_REPAIR_FAST` | off | `1` = 20 ms pause between titles instead of the TMDB pacing — the gate only (fake TMDB) || `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) || `TRACKER_CREDITS_LOG_EVERY` | 500 | a progress line every N titles || `TRACKER_CREDITS_FAST` | off | `1` = 20 ms pause between titles — the gate only (fake TMDB) || `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 || `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 |Without `TRACKER_KEY`/`TRACKER_SECRET`, `/` still renders (signed out, the selector and "Login with ident" show in the header); a login attempt answers `'login is not set up on thisserver (TRACKER_KEY / TRACKER_SECRET missing)'` instead of exchanging a code — so the app isnever a dead 500 while waiting for that one-time setup.## What it does (step 1)* **Login, copied unchanged from calendar.worldapi.org** (rejected once for a centeredsign-in and no header selector — architect, 2026-09-27): ident only, no own passwords(ident's login button flow, ident.worldapi.org README "How apps use ident"), the identityselector (`<ident-selector>`, ident's `/selector.js`) sits in the header(`components/main.hl`, `userBox`), a plain "Log in with ident" link beside it when signedout. Choosing an identity in the selector fires `ident-login` (one-time code); `login.js`(an external-component bridge, allowed per the creator: "login.js is correct, as thats forexternals in general") hands the code to a hidden input, whose `change` calls the serverface `trackerLogin` (`users.hl` `exchangeCode`, `POST <ident>/api/exchange` — nothing else:ident does not hand over a display name yet, ident#23 done / ident#11 on hold), which makesor finds this app's own user record (`usersTable`, `storage/mpackdb/users.db`) and sets`session.user = { id }`. `/login/callback` is the same exchange as a plain redirect (thelogin button's return URL) for a client that has no socket yet. "Log out" in the headercalls `trackerLogOut`; ident's own login is untouched. This app's own session cookie is`trackersid` (cookies ignore ports, an own name keeps it apart from ident's `identsid` onthe same dev host, hybriel#10/#17).* **The empty homepage** (until #13 — see "What it does (step 13)") (`/`, `components/home.hl`): no content at all, signed in or out —the header alone carries the login state. Nothing else.* **One mpackdb table**: `storage/mpackdb/users.db` (`identity` → this app's user id — nodisplay name, no other per-user data yet). Step 2 (tracker.worldapi.org#2, open) brings theold tracker's data across.## What it does (step 4)`/shows/:slug` (`components/show.hl`, slug = the migrated `urlSegment`), read-only data from`shows.hl`, per-user watch state from `watches.hl` (both reused as-is by `/unwatched` and`/schedule`, tickets #5/#6 — "reuse, don't copy"):* **Header**: poster on the left (`/posters/<name>`, `project.hl` `posterRoute` — served from`storage/mpackdb/posters/`; the real poster files were never in the Mongo backup, see`DECISIONS.md` "tracker concept" — a placeholder SVG shows until a later TMDB step bringsreal ones back), title, genre pills (blue, link to `/genre/<slug>`) and the plot summary toits right, the cast below as comma-separated red links to `/person/<slug>`.* **Seasons**, latest first, the latest expanded and every other one collapsed to start; eachrow: a round check icon (a disc with a check, drawn in SVG/CSS, no icon font), the seasontitle/number, its episode count. Its episodes (number + title, and their own check icon)show once expanded (click the row to toggle).* **The watch check** (signed-in only — signed out, no check icons show at all): clicking anepisode's check toggles that one watch; clicking a season's check toggles the season's ownwatch AND every one of its episodes at once (`watches.hl` `setSeasonWatched`). Every click isa server round trip (`emit server showToggleEpisode` / `showToggleSeason`; collapse/expand: seeticket #17 below) that rebuilds the row list server-side and reassigns it whole — a season hasat most a few dozen episodes, cheap. A **Hybriel gap found here** (filed as hybriel#115,reported after this ticket's first attempt): a plain per-instance member function that callsan hl:mpackdb-backed import comes back `null` when called from inside an `on server` face,even though the very same call works as a member's own top-level initializer — the fix is todeclare such helpers `static` (the same shape calendar.worldapi.org's `soonOf` already uses).`components/show.hl`'s own helpers (`buildRows`, `genreRowsOf`, …) are `static` for exactlythis reason.* **Data fix**: `tools/relink-episode-seasons.hl`, a one-off idempotent tool that links everyepisode whose `season` was still null in the migrated data (36,194 of them, ticket #2) to itsseason by matching `(show, seasonNumber)` — never touches an episode whose season is alreadyset, never runs `update()` on the live table (hybriel#113; it rebuilds a fresh episodes tableand swaps the files over, the same rule `tools/migrate.hl` follows). Not run against the livedata yet — the architect runs it (stop the container first, `storage/mpackdb` is not sharedacross processes, hybriel#21).## What it does (step 5)`/unwatched` (`components/unwatched.hl`): every episode of a show the signed-in user follows(`follows.hl`, new) that is already released (`release` ≤ today) and not yet watched(`watches.hl` `isWatched`), newest release first. Creator: "/unwatched lists all unwatchedepisodes of shows followed in release date desc order"; "check icon is a disc with a check init" — the SAME icon/class (`watches.hl` `watchClassOf`, moved there from `components/show.hl` soboth pages share one definition instead of each declaring its own) and the same`setWatched`/`isWatched` watch logic as the show page (step 4), not copied.* Each row: check icon, the show's title (linking to `/shows/:slug`), "Ep `<number>` ·`<title>`", the release date.* A row only ever shows an unwatched episode, so a click always marks it watched (never togglesback, unlike the show page's checks) — the row leaves the list. Signed out: no rows, a"Sign in to see the shows you follow." message instead.* `follows.hl` (new): read-only per-user follow data (`followedShowIds`), kept apart from`shows.hl`/`watches.hl` the same way, for `/schedule` (ticket #6) to reuse.## What it does (step 6)`/schedule` (`components/schedule.hl`): every episode of a show the signed-in user follows(`follows.hl`) that is NOT yet released (`release` > today), soonest first. Creator: "/schedulehas a similar list without check icons for the upcoming episodes of shows followed" — same rowlayout as `/unwatched` (show title linking to `/shows/:slug`, "Ep `<number>` · `<title>`", therelease date), minus the check icon and minus any click handling: the page is read-only, so itneeds no `on server` face at all. Signed out: no rows, the same "Sign in to see the shows youfollow." message as `/unwatched`.## What it does (step 7)`/my/shows` (`components/myshows.hl`): every show the signed-in user follows, ordered by thefollow's `at` (epoch ms), newest first. Creator: "trackers /my/shows that just ists the shows indesc order i followed them, small poster, title, last episode like s08e35". Read-only, no `onserver` face (like `/schedule`).* Each row: a small poster (2.5rem wide, the same `/posters/<name>` route + placeholder as the showpage; a show with no poster name at all gets `/posters/none`, i.e. the route's placeholder), thetitle linking to `/shows/<slug>`, and the **last watched episode** as `SxxEyy` (zero-padded totwo digits) = the HIGHEST season/episode number the user has an episode watch for (architect'sreading, in the ticket) — not the most recently watched one; season watches alone don't count.Nothing watched → no episode element at all.* Signed out: no rows, the same "Sign in to see the shows you follow." as `/unwatched`.* Cost: touches only the user's follows + episode watches (one `episodeById` fetch per watch),never all episodes of a followed show — 128 rows in ~0.2 s on a copy of the real data (STATUS.md).* Reuse: `follows.hl` `followsOfUser` (new — the whole follow record, `followedShowIds` nowbuilds on it), `shows.hl` `episodeById` (new, one line), `showById`, `posterName`, `watches.hl``watchesOfUser`.## What it does (step 8)* Routes (`project.hl`): `/my/unwatched`, `/my/schedule`; the old `/unwatched`, `/schedule` arefunction routes answering **301** to them (`movedTo`, no query carried — the pages take none).* `shows.hl` (shared by all lists): `episodeCode(s, e)` → `S01E01` (`/my/shows`, `/my/unwatched`,`/my/schedule` rows: `S01E02 · <title>`), `titleWithYear(show)` → `Doctor Who (2005)` (`year`,else the first 4 chars of `release`, else the bare title — 197 shows have no `year`), `pad2`,`posterUrlOf(show)`. The show page's own `h1` stays without the year (it is not a list).* Show page: season count `1 episode` / `N episodes`.* **Speed** (the creator's real data: 128 follows, 11,692 watches, 831 unwatched rows):* `/unwatched` was ~15 s: `isWatched` scanned all 11,692 watches per episode. Now`watches.hl` `watchedSet(rows)` builds `'<type>:<id>' → true` ONCE; `/my/unwatched` and theshow page's `buildRows` look up that map. Now ~0.6 s server render (the rest is fetchingevery episode of 128 shows; the insertion sort is ~50 ms).* `/my/shows` "25 s full load while data takes 0.2 s": (1) **hl:web serves one request at atime** — measured: a `/my/shows` request made during a 15 s `/unwatched` render waited 13.7 s;every poster request of every visitor queues the same way. Fixing `/unwatched` removes that.(2) 128 rows = 128 different `/posters/<oldId>.jpg` URLs, all answering the same placeholder(no poster files exist yet) — 128 requests per load. `posterUrlOf` now gives rows without aposter FILE the one URL `/posters/none` → 1 request, cached.* How it was measured: see STATUS.md ticket #8 ("Real-data check").## What it does (step 9)`tmdbsync.hl` (`syncShow`), run by the app every day and by `tools/sync-tmdb.hl`:* **Which shows**: every show ANY user follows (`follows.hl` `allFollowedShowIds`, 126 on the real data). **Since #18 only the"full walk"** (first run, gap > 14 days, failed change list) — the normal daily run takes the changed titles (ticket #18 below).* **Per show** (1 + ⌈seasons/20⌉ requests + the poster if new): `GET /tv/<tmdbId>?append_to_response=external_ids`(status, counts, overview, `poster_path`, the season list, since #12 the external ids), then `GET /tv/<tmdbId>?append_to_response=season/1,…` (≤ 20seasons with their episodes per request). Bearer `TMDB_READ_TOKEN`.* **Matching, never duplicating, never deleting**: episode by `tmdbId`, else (161 migrated episodes havenone) by S/E number if that one has no tmdbId yet — then it gets the tmdbId; season by (show,seasonNumber) (migrated seasons have no tmdbId). Existing episodes/seasons: title, summary, releaseupdated; an empty TMDB value never overwrites. New ones: self-assigned 16-hex ids (NOT mpackdb's own —hybriel#113), `oldId = 'tmdb-episode:<id>'` / `'tmdb-season:<showTmdb>:<n>'` (the tables' `!oldId`index), linked into `season.episodes` / `show.seasons`. Show: `status`, `seasonsCount`,`episodesCount`, `tmdbSummary`, `tmdbPoster`, `tmdbSync` (ms). Season 0 (specials) only for a show thatalready has one; a TMDB season with no episodes is not created.* **Posters**: TMDB `poster_path`, size w342 → `storage/mpackdb/posters/<oldId>.<ext>` (the folder theexisting `/posters/:name` route already served; inside `storage/` → in the nightly backup), `show.image`set to the ext. Downloaded again only when the file is missing or TMDB's `poster_path` changed.* **Daily run inside the app** (`project.hl` `syncTick`): an hl:time `every(1)` clock; at`TRACKER_SYNC_HOUR` (UTC), once per day, it queues all followed shows and then syncs ONE show per tick.hl:web serves one request at a time and `fetch()` blocks the whole process, so a step blocks requestsfor its duration (real data: ~0.7 s average, Saturday Night Live 53 seasons ~1.7 s; logged as`tmdb sync: slow step …` above 1.5 s) — requests queued meanwhile are served between two ticks; a ticknever runs inside a page render. After a show with n requests the next waits n × 260 ms (TMDB ≤ 40requests / 10 s). Log: `tmdb sync: daily at 4:00 UTC`, `tmdb sync: start, N followed shows`,`tmdb sync done: shows=… newSeasons=… newEpisodes=… updatedEpisodes=… updatedSeasons=… posters=…requests=… errors=… seconds=…` (`docker logs tracker.worldapi.org | grep tmdb`). Tables are persistedevery 10 shows and at the end (~36 ms).* **The tool** (same sync, sequential, paced; prints one line per show + the totals). **App stopped or aCOPY only** — hl:mpackdb is one process per storage (hybriel#21):```bashset -a; . ./.env; set +a # TMDB_READ_TOKEN, never print itTRACKER_STORAGE=$PWD/.scratch/realdata/storage/mpackdb ./bin/hybriel tools/sync-tmdb.hl [--limit N]```* Real data (copy, 2026-09-30): first run 126 shows → +20 seasons, +800 episodes, 2651 episodesupdated, 125 posters (6.4 MB), 380 requests, 0 errors, 186 s; a second run: all 0, 255 requests, 90 s.Details: STATUS.md ticket #9.## What it does (step 10)The installable app, the same way calendar.worldapi.org does it (its ticket #3) — hl:web's own manifestand service worker from settings in `project.hl`, no JavaScript of ours:* `appIcons` (192 + 512 PNG, each `any` and `maskable`), `appTouchIcon` (180 PNG), `appFavicon`(`/icons/favicon.svg`), `appThemeColor` = token `darker` (the header, rgb(15, 20, 25)),`appBackgroundColor` = token `dark` (rgb(25, 30, 35)); name = `appTitle` "tracker". hl:web serves`/__hl/manifest.webmanifest` (start_url/scope `/`, display standalone) and links it, theapple-touch-icon and theme-color from every head. `/favicon.ico` is a real icon (16/32/48) forbrowsers that ask for it themselves. Each icon has its own `file` route in `project.hl`.* **Icons** (`icons/`): `icon.svg` is the source (512, hand-written: a TV with antennas and a check onthe screen, `#4ec9b0` on rgb(25,30,35); everything inside the maskable safe zone, a circle of radius204, so one image serves `any` and `maskable`); `favicon.svg` is the same drawing cropped tight on arounded tile. Rendered on Loreana:```bashrsvg-convert -w 192 -h 192 icons/icon.svg -o icons/icon-192.pngrsvg-convert -w 512 -h 512 icons/icon.svg -o icons/icon-512.pngrsvg-convert -w 180 -h 180 icons/icon.svg -o icons/apple-touch-icon.pngfor s in 16 32 48; do rsvg-convert -w $s -h $s icons/favicon.svg -o /tmp/fav-$s.png; donemagick /tmp/fav-16.png /tmp/fav-32.png /tmp/fav-48.png icons/favicon.ico```* **Offline**: `offline = [ Home ]` — the worker precaches the shell (runtime, modules, CSS, manifest,icons, favicon) and the document of `/`. Navigations are network-first; without a network `/` comesfrom the cache and every other page gets hl:web's own "Unavailable offline" page (503) — data pagesneed the network, no offline data. The shell (`components/main.hl`) shows "You are offline. Your showsand lists need the network." while `navigator.onLine` is false: hl:web gives a page no connectionstate and no mount hook, so an invisible `netProbe` runs an endless 1 s CSS animation (`styles.hl``@keyframes tracker-net-tick`) and its `animationiteration` handler reads `navigator.onLine` (a writeonly when it changes). A server that is down while the device is online shows no note.## What it does (step 12)* **Link icons** (`components/show.hl`, `shows.hl` `externalLinksOf`, `styles.hl` `showLinks`/`a.extlink`): under thetitle (phone: between the title row and the full-width poster; desktop: under the title, right of the poster) onesmall monochrome badge per id the show HAS — `TMDB` https://www.themoviedb.org/tv/<tmdbId> (a movie: `/movie/<id>`),`IMDb` https://www.imdb.com/title/<imdbId>/, `TVDB` https://thetvdb.com/dereferrer/series/<tvdbId>, `TVmaze`https://www.tvmaze.com/shows/<tvmzId>. A movie (`type = 'movie'`) shows only TMDB + IMDb. Each `target="_blank"rel="noopener"`. CSS only (muted grey badge, dark text, accent on hover), no copied logos. URL forms checked2026-10-01: all four 301 to the site's slug page (TheTVDB's old `?tab=series&id=` too; `dereferrer` is its id form).* **Sync** (`tmdbsync.hl` `externalIdsFor`): the details request carries `append_to_response=external_ids` (no extrarequest); a MISSING `imdbId` (`tt` + digits) / `tvdbId` (number > 0) is filled from it. A show still without`tvmzId` → `GET <TVMAZE_BASE_URL>/lookup/shows?imdb=tt…`, on 404 `?thetvdb=<id>` (hl:fetch follows TVmaze's 301 to`/shows/<id>`); 550 ms pause per lookup (TVmaze 20 / 10 s) — both runners wait `pauseMsAfter(r)`. **An id the showhas is never overwritten.** Totals gain `ids=` (ids filled) and `tvmazeRequests=`.* **Movies** (7814 of 9453 migrated rows are `type = 'movie'`, 1 followed: Star Trek: First Contact): before #12 thesync asked `/tv/<tmdbId>` for them — a DIFFERENT title on TMDB. Now a movie gets one `GET/movie/<tmdbId>?append_to_response=external_ids`: missing imdbId + its poster; no seasons/status/overview.* **Counting ids** (a COPY only): `TRACKER_STORAGE=$PWD/.scratch/realdata/storage/mpackdb ./bin/hybrieltools/count-external-ids.hl` → per type, all / followed: shows, tmdbId, imdbId, tvdbId, tvmzId.## What it does (step 15)* **TVmaze merge** (`tmdbsync.hl` `mergeTvmaze`, after the TMDB step of `syncShow`, both runners): a series with a`tvmzId` → `GET <TVMAZE_BASE_URL>/shows/<tvmzId>/episodes` (1 request, 550 ms pause like the lookups). Matched byseason/episode number: a missing episode is ADDED (`tmdbId = null`, `tvmzId`, `oldId 'tvmaze-episode:<id>'`; itsseason too if missing: `oldId 'tvmaze-season:<tvmzId>:<n>'`), an existing one gets an EMPTY title / air datefilled. **A TMDB value is never overwritten.** Placeholder titles (`Episode 4`, `Épisode 4`, `Folge 4`, `TBA`,`isPlaceholderTitle`) count as empty — and TMDB's placeholder no longer overwrites a real title (else they flipdaily). When TMDB lists the episode later, the TMDB step adopts the row by number (it has no tmdbId), TMDB wins.Skipped: TVmaze specials (no number), season 0 unless the show has one, summaries (TVmaze's are HTML).* **Numbering check** (`numberingAgrees`): ≥ 80 % of the episodes both know must air within a day OR share the title,and a show with episodes must share at least one S/E with TVmaze — else TVmaze is ignored for that show(`tvmazeSkipped`). Real data: The Daily Show (TVmaze seasons by year: would have added 1093 episodes), Star Trek:Prodigy / Blood & Treasure (TMDB merges double episodes → shifted), Alex Rider / Intergalactic / The Rising (otherpremiere dates, placeholder titles) are skipped.* Totals/tool line gain `tvmazeEpisodes= tvmazeSeasons= tvmazeFilled= tvmazeSkipped=`; `tvmazeRequests` now alsocounts the episode lists (≈ 125/day on real data, ~70 s more per daily run).## What it does (ticket #14: search)Creator: "in the actionbar a search symbol that first checks our database and then has a fetch from web button thatactually searches tmdb then for things we dont have yet". `components/search.hl` (page), `search.hl` (index, TMDB, import).* **Header**: a magnifier (`#searchlink`, SVG) before the ident selector → `/search` (client-side navigation).* **Address `/search/<text>`, not `/search?q=`**: hl:web gives a page its route params but no query string. A full load getsthe segment already decoded by the server (an encoded `/` would split it → 404), a client-side navigation the raw one —`queryOfParam` decodes what is left, safely (malformed `%` → literal). While typing the address follows the text(`history.replaceState`; `/ ? #` become spaces). `/search?q=x` just opens the empty search page.* **Our database, as you type** (face `searchDb`, no session): `search.hl` builds an **in-memory index once at boot**(`project.hl` → `ensureIndex`, ~0.6 s on the real data, log line `search index: 9453 titles, 15157 people, 4180 keys,… ms`): every title/name → words (lower case, apostrophes dropped, punctuation = word break, Latin accents folded; atitle's year is one more word); each word's first 2 and 3 letters are bucket keys. A query reads ONE bucket (its longestword's first 3 letters, 2 for a 2-letter word) and keeps entries where every query word starts one of the entry's words.Order: whole name, name starts with the text, first word does, rest; then shorter name; then series before movie, newerfirst. ≤ 30 titles ("The first 30 of N titles — type more…") + ≤ 10 people. Under 2 letters: "Type at least 2 letters.".People link to `/person/<slug>` (no person page yet — same as the show page's cast).* **Fetch from web** (face `searchWeb`, signed out too): ONE `GET <TMDB>/search/multi?query=…&include_adult=false&page=1`;people dropped, titles we have (same TMDB id AND kind: `series:<id>` / `movie:<id>`) left out. Poster thumbs straightfrom TMDB's image host (`w92`).* **Add** (face `searchImport`, session): signed out → the sign-in modal, nothing sent. Signed in → `importTitle`: one`GET /tv/<id>` (or `/movie/<id>`) for the new record (title, overview, first air/release date → year, status, genresmatched to OUR genres by name, language, homepage, tagline; own 16-hex id, `oldId = tmdb-tv-<id>` / `tmdb-movie-<id>`,`urlSegment` like the migrated ones: `Pluribus`, taken → `-2`…), then **`tmdbsync.hl` `syncShow`** — seasons, episodes,poster, external ids exactly like the daily sync — `persistSync`, index updated, the public lists too (`catalog.hl``refreshCatalogShow`, since the #13/#14 merge); then the show page opens. A title wealready have answers its slug. A failure (TMDB 404 …) is shown on the page ("Could not add it: …"), the lists stay.Log: `search import: tv 225171 "Pluribus" → /shows/Pluribus (newSeasons=1 newEpisodes=9 posters=1 ids=3 requests=4 errors=0)`.* An imported title is NOT followed automatically, so the daily sync (followed shows only) does not update it later.* Hybriel traps (hybriel#121 / #122): no list is ever appended to (replaced whole); no member reads both the route param`q` and `session`; the gate checks the lists after a failed import (a session face that stays on the page).## What it does (ticket #16: person pages)Creator: "when one clicks on an artist that has no dataset yet the old version fetched that artist and its entire bibilogrqphyso its profile was complete". `components/person.hl` (page), `people.hl` (data + the fetch).* **`/person/<slug>`** (slug = the person's `urlSegment`; `people.hl` builds a slug → id map once, ~15k people): photo (TMDBw185 → `storage/mpackdb/profiles/<oldId>.<ext>`, route `/profiles/:name` like `/posters/:name`; no file → no photo), name,"Born 4 March 1986 in …", "Died …", the bio (four lines, a click shows all), then **Filmography**: one row per title (cast andcrew of the same title merged: "Hero / Director"), poster (our file, else TMDB's w92 thumb like search's web rows, else theplaceholder), title, year, Movie/Series, role; newest first by the title's release (else year), undated last; each row →`/shows/<slug>`. Only PUBLIC titles are listed (merge, mission 056: shows.hl `isPublicTitle` = `adult == false`, unknown hidden like the lists) whose credit's TMDB flag is not adult either. Unknown slug →"Not found".* **Complete** = `persons.filmographyAt` (ms) set → the page is just read, TMDB never asked again. Not complete (every migratedperson; 153 carry the old tracker's credits, shown meanwhile and kept) and a `tmdbId` → **the fill**:* the page shows "Loading filmography…"; an invisible `personProbe` (endless 0.1 s CSS animation, the same trick as theshell's offline tick — hl:web has no mount hook) fires `animationiteration` → the client handler calls the face`personFill` again and again until `done` ("Loading filmography… 40 of 87 titles"), then replaces the rows and facts —no reload, works after a client-side navigation too.* step 1: `GET <TMDB>/person/<tmdbId>?append_to_response=combined_credits` (ONE request) + the photo; facts updated (emptyTMDB values never overwrite: biography, birthday, deathday, place_of_birth, imdb_id, adult). Every next step: 40 titles.A title we have (search.hl `titleIdByTmdb`, TMDB id + kind) is linked; one we don't gets a MINIMAL show record (title,overview, year/release, type, tmdbId, `adult`, language, genres by TMDB id, `cast` = this person, no seasons/poster;`oldId tmdb-<tv|movie>-<id>`, a free slug — search.hl's `slugOf`), indexed for the search (`indexShow`) and put into thepublic lists (`catalog.hl refreshCatalogShow`). Last step: `person.shows` = the credits `{ show, character, posterPath,adult }` (+ migrated credits TMDB no longer lists), `filmographyAt`, `persist()`. Log:`person fill: Jared Harris (tmdb 15440) credits=97 added=80 requests=2 steps=4 tmdbMs=287 ms=624`.* hl:web serves one request at a time and `fetch()` blocks: the TMDB step blocks other requests for its duration(~0.25–0.65 s real), each 40-title step ~0.15 s; other requests are served between steps (real data: a 758-creditperson, 20 steps, 3.8 s, other requests waited ≤ 0.61 s).* the job lives in memory (`people.hl jobs`); a restart mid-fill loses it — the next visit starts again, titles storedalready are found by TMDB id (no duplicates). A TMDB failure shows "Could not load the filmography: …"; the next visitretries. No sign-in needed (no session face on this page — hybriel#121/#122 can't bite).## What it does (ticket #26: titles from a filmography are complete)Creator: "when i go to an artist and it loads the bibliography those loaded movies and series miss most information and it staysthat way when i click on them". `details.hl` (the completion), `components/show.hl` (loading state), `project.hl` (`repairTick`).* **What the filmography stored** (people.hl `addMinimalTitle`, #16): title, year/release, overview, type, tmdbId, `adult`, genres(by TMDB id), language, `cast` = only that person; NO poster file, seasons/episodes, imdb/tvdb/tvmaze ids, homepage, tagline,status, `tmdbSync`. A search import (search.hl `importTitle`) has all of those via `syncShow` — but no cast either. Real data2026-10-01: 321 such titles (6 people filled), every open showed exactly what the filmography stored.* **Incomplete** (details.hl `isIncomplete`): `minimal = true` (set by the fill from now on) or `imported` without `tmdbSync`(the 321 existing ones, and an import whose sync failed), no `detailsAt`, no `detailsCheck`. Adult titles are never completed.* **Complete** (`completeStep`, called until `done`): step 1 = ONE `GET /movie|tv/<id>?append_to_response=external_ids,credits|aggregate_credits` (fields + cast; a movie also its ids + poster → done). A series then goes on IN STEPS with thatanswer (tmdbsync.hl `syncShowPart`, the daily sync's code without a second details request): one step per part of its seasons(≤ 300 episodes, ≤ 20 seasons = one `season/…` request; `TRACKER_DETAILS_PART_EPISODES`), a last step = ids, TVmaze lookup +episode merge, poster. Why: The Late Show (22 seasons, 4,252 episodes) as ONE step blocked the server 10.7 s (hl:web serves onerequest at a time) — in parts every other request is served between two steps. The job state lives in memory (`jobs`); arestart begins the title again, stored seasons/episodes are matched by tmdbId. Stored too: overview (summary only if ours isempty), homepage, tagline, status, genres (TMDB ids → ours), release/year/language when missing, and the **cast** (+ crew since#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,a new one is added (people.hl `castPersonId`: name, tmdbId, free slug, `oldId tmdb-person-<id>`, this credit — their page fillsthe rest on the first visit; in the search at once). Then `detailsAt` (ms). TMDB 404 → `detailsCheck = 'TMDB 404'`; TMDB saysadult → flag stored, `detailsCheck = 'adult'`, nothing downloaded. TVmaze/poster failures are logged (`detailsError`), the titlestill counts complete (the daily sync refreshes followed titles).* **On open** (`components/show.hl`): an incomplete, non-adult title renders what it has + skeletons (poster box, a cast line, 3season rows for a series; the #17 shimmer). An invisible `showProbe` (CSS animation tick, like the person page — no mount hook)calls the face `showComplete(slug)` (no session → no page re-mount) again and again — one step per call — until `done`; thelast answer (title, summary, poster, genres, cast, links, collapsed, rows) is assigned on the client, no reload. Persisted at theend. Log: `details on open: "<title>" steps=… requests=… tvmazeRequests=… cast=… newSeasons=… newEpisodes=… posters=… ids=…ms=…`. A second viewer drives the same job; a reload after it: complete at once.* **The repair job** (`project.hl repairTick`, the app's clock — after the adult backfill is done, never during a daily run): atstart every incomplete non-adult title (`detailsRepairQueue`), ONE `completeStep` per tick (a series: the same title until it isdone), then `pauseMsAfter(step)` (260 ms per TMDB + 550 ms perTVmaze request). Resumes after a restart (the queue = what is still incomplete); passing failures (no answer, 429, 5xx) go to theback (≤ 2 more tries per start). Persisted every 10 titles. Log: `details repair: start, N incomplete titles`, every 100:`details repair: 100/401 titles, complete=… failed=… adult=… newSeasons=… newEpisodes=… posters=… cast=… requests=… tvmazeRequests=…retries=… seconds=…`, each failure, `details repair done: …`, slow steps (> 1.5 s). Titles a fill adds while the app runs arecompleted when opened or at the next start.* 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);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,528episodes, 299 posters; /movies + a show page meanwhile median 0.13 s, p99 1.0 s, max 2.4 s. STATUS.md ticket #26.## What it does (ticket #28: full cast and crew)Creator: "you show quite few people, usually there are more especially at picard, and directors and so". `details.hl` (what isstored), `components/show.hl` + `styles.hl` (the page), `project.hl` (`creditsTick`).* **Before**: the page showed `show.cast` — for 1,820 of 10,011 titles the old tracker's migrated list (Star Trek: Picard 4 names, nocharacters shown), nothing for the rest (Superman 1978: 0); #26 added TMDB's first 20 for filmography titles only; no crew anywhere.* **Stored** (details.hl `withCredits`, from a details answer with `credits` (movie) / `aggregate_credits` (series = every season)appended): `cast` [{ person, character, actor, episodes, guest }] — TMDB's order (a series: by episode count; roles " / "-joined),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 notlist kept after them; `guest` = fewer episodes than `mainMinOf` (half the series' episodes, ≤ 10, ≥ 1). `crew` [{ person, name,job, episodes }] — job creator (series `created_by`) / director (Director) / writer (Writer) / screenplay / story / composer(Original Music Composer, Composer, Music); one entry per person and job; a series' by that job's episodes, a movie's in TMDB'sorder; ≤ 30 per job. `creditsAt` (ms). Every person linked by TMDB id; a new one gets a minimal record (people.hl `castPersonId`,#26: name, slug, search entry, this credit; their page fills on the first visit).* **Who stores them**: the completion of an incomplete title (#26 `completeStep`, same request), the daily sync and`tools/sync-tmdb.hl` (tmdbsync.hl now asks `/tv/<id>?append_to_response=external_ids,aggregate_credits` / `/movie/<id>?…,credits`— no extra request — and hands the answer back as `res.details` → `applyCredits`), the search import (`importTitle` asks ONCE withids + credits and passes that answer to `syncShowWith` — one request less than before; the face `searchImport` stores thecredits, log `search import credits: /shows/<slug> cast=… crew=… people=…`), and the **credits job** (`project.hl creditsTick`):at start (after the adult backfill and the details repair are done, never during a daily run) every public title (`adult ==false`) with a tmdbId, no `creditsAt`/`creditsCheck`, not incomplete (`creditsQueue`); ONE request per step(`/movie|tv/<id>?append_to_response=credits|aggregate_credits`), then 260 ms. Resumes after a restart (the queue = what has nocredits); 404 / adult → `creditsCheck`; passing failures to the back (≤ 2 more tries). Persisted every 25 titles. Log: `credits job:start, N titles without credits`, every 500 `credits job: 500/N titles, done=… failed=… cast=… crew=… people=… requests=…retries=… slowestStepMs=… seconds=…`, failures, slow steps (> 1.5 s), `credits job done: …`. Adult titles: never. Persists onlyshows + people (`persistCredits`, not the 16 MB episode indexes). MEMORY: before each title it reads its RSS (/proc/self/status);above `TRACKER_CREDITS_MAX_MB` (16000) it logs `credits job: paused, RSS … MB > … — N titles left for the next start` and stopsfor this process (the native runtime never frees what a fetch/JSON decode took — see STATUS #28).* **`showBySlug`** (shows.hl) is a slug → id map now (one scan at the first call, a miss = one scan, remembered): the old per-callscan decoded every record — 82 ms per call, twice per show page, and it grew with the stored credits (and leaked memory).* **The page**: crew first (under the plot; a small label per job, the names comma-separated), then "Cast": `Name (Character)`, maincast, then a "Guest stars" label and the guests. The first 20 cast names show, the rest after "Show all (N)" (→ "Show less"); percrew job the first 6, the rest after "Show all crew (N)". The toggles are CLIENT-ONLY (`castClass`/`crewClass` member flip, CSShides `.more` while `collapsed`; no face). The character + comma are the link's `::after` (`data-role`) — with a wrapper element theSSR's line breaks put a space before every comma. A completed #26 title gets cast + crew in its last `showComplete` answer.* Real data (copy 2026-10-01, real TMDB): Picard 4 → 202 cast (17 main, then guests) + 45 crew; Superman (1978) 0 → 90 cast, Donner /Mankiewicz / Puzo / John Williams; the credits job did 5,867 titles in ~51 min (0 failed; +105k people in the 2nd run), pagesmeanwhile 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:people 15k → 160k, search index at boot 0.66 → 4.3 s, RSS after boot 1.6 → 4.9 GB. STATUS.md ticket #28.## What it does (ticket #18: the daily sync by change lists, the show record, summaries)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 changeddescriptions and titles and that did not update". `deltasync.hl` (the plan), `project.hl` `syncTick` (the run), `tmdbsync.hl`(`syncShowDelta`, the TVmaze summary), `details.hl` (`withDetailsFields`, `applySynced`), `shows.hl` `summaryOf`.* **The run** (04:00 UTC, as before; one step per clock tick, requests served between steps): `GET /tv/changes` and`GET /movie/changes?start_date=<last run day>&end_date=<today>&page=n` (100 ids per page, all pages, the Bearer token) and`GET <TVmaze>/updates/shows?since=day|week|month` — one request per step, 260 / 550 ms between them. Intersected with OUR titles viathe search index (TMDB id by kind, TVmaze id; no table scan) → the queue, followed titles first. Left out: no tmdbId, incomplete(#26's repair does them), adult titles nobody follows; TVmaze's list brings only FOLLOWED series (TVmaze is merged for those only).* **Per title**: a FOLLOWED one gets the full step as before (`syncShow`: all seasons, TVmaze merge, poster, ids); one NOBODY followsthe LIGHT step (`syncShowDelta`): details + ids + poster + the record, and only the seasons that can have changed — the ones wedon't have, the ones where TMDB counts more episodes than we do, the newest one — no TVmaze. (Real data: Today 15,716 episodes /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.* **The show record** (`applySynced` → `withDetailsFields`, the same details answer, no extra request): title (series `name`, movie`title`; the slug stays), TMDB overview → `tmdbSummary`, homepage, tagline, status, genres, adult; type/release/year/language onlywhen empty; cast + crew (#28). A renamed title is re-indexed for the search at once (`searchTitleRenamed`). Empty TMDB values neveroverwrite. TVmaze: the series request is `GET /shows/<id>?embed=episodes` now (show + episodes, one request, was `/episodes`) — itssummary, HTML stripped (`stripHtml`), → `tvmazeSummary`.* **Summaries**: `summary` = the creator's own text, written by nothing (sync, import, person fill, completion). The page shows`summary` > `tmdbSummary` > `tvmazeSummary` (`shows.hl summaryOf`). ONE TIME at start (`clearCopiedSummaries`, marker`storage/mpackdb/summaries-cleared.txt`): a `summary` identical to `tmdbSummary` (the migration's copy) is cleared. Live copy2026-10-01: **9,647 cleared, 24 kept** — all 24 are followed shows whose tmdbSummary the #9 sync changed since (older TMDB texts,probably no rewrites; list in STATUS #18).* **The last run**: `storage/mpackdb/sync-state.txt` = the UTC day the last FINISHED run started. FULL WALK (followed shows, asbefore) when it is missing (the first run after the deploy), more than 14 days old (TMDB's lists reach 14 days), or a change listfails (then the day is not advanced). Log: `tmdb sync: start, changes since <day> (n days)` / `tmdb sync: start, N followed shows,full walk (<why>)`, `tmdb sync: changes since …: tmdb tv=… movie=… tvmaze(day)=… → N of our titles (M followed), changeRequests=…`,`tmdb sync done: mode=delta|full changeRequests=… shows=… … requests=… tvmazeRequests=… errors=… seconds=…`.* **The tool**: `tools/sync-tmdb.hl --delta [--since YYYY-MM-DD] [--plan]` = the app's run from the command line (app stopped / acopy); `--plan` only the change lists + the queue; the day is stored only by a plain `--delta` run. Without `--delta`: the full walk.* 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 →**336 of our titles (28 followed)**; the run: 128 change requests + 477 title requests + 29 TVmaze, **511–536 s**, 0 errors, sloweststep 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 TVmazerequests, 312 s. First day only: +79 seasons / +4,475 episodes (unfollowed series never synced since the migration), 24 titlesrenamed (TMDB's en-US name, e.g. "Star Wars: Andor" → "Andor", "Kaun Banega Crorepati(कौन बनेगा करोड़पति)" → "Kaun BanegaCrorepati"), 79 TMDB texts, 173 taglines. MEMORY: ~10 MB per synced title are never given back (the runtime, see #28) — a deltarun +3.5 GB RSS, the old full walk +2.8 GB. Details: STATUS.md ticket #18.## What it does (mission 047: checks after client-side navigation, mobile first, show page)* **Invisible checks fixed** by re-vendoring Hybriel master ff51cf46 (see "Vendored Hybriel"): the oldbuild created SVG built in the BROWSER (client-side navigation, expand/collapse, a check click) in theXHTML namespace — nothing drawn. Server-rendered pages were fine.* **Mobile first** (`styles.hl`): every rule outside the one `@media (min-width: 40rem)` block is thephone's (390 px); the block only adds the wide layout. The header is one row on a phone (brand, identselector, Log out; signed out — mission 056 — the selector + "Log in", " with ident" only from 40rem, nowrap); `/my/unwatched` + `/my/schedule` rows are two lines on a phone (show + date, theepisode below), one line from 40rem.* **Show page** (`components/show.hl`): phone = title (+ Follow) first, then the poster at the fullcontent width, then genres/plot/cast (`showMeta` is `display: contents` there, the title row`order: -1`); desktop unchanged (poster 10rem left). Season header: a caret at the right edge (sinceticket #17: DOWN = closed, `season-row.expanded` turns it UP). Episode rows are not indented (episode check under theseason check). Artists orange (`#ce9178`, token `orange`). Unwatched check = the inverted solid icon:accent outline circle + accent check mark; watched = filled accent disc, dark check.* **Follow toggle** (signed in): "Follow" = the inverted button (accent border), "Following" = filled;face `showToggleFollow` → `follows.hl` `setFollowed` (new record, `at` = now → top of `/my/shows`;unfollow deletes the user's records for that show). **Signed out** the checks and Follow are visible;a click opens the modal "You need to sign in to follow shows and mark episodes." (Close + "Sign in withident" = the ident login link) and sends nothing. The modal is `components/modal.hl`, copied verbatimfrom components.hybriel.worldapi.org `components/modal/modal.hl` (becbd59, 2026-09-30) — its own`Style` lands in `/__hl/app.css`.## What it does (step 13)* **`/`** (`components/home.hl`): signed in, four icon tiles (inline SVG, accent) → `/my/unwatched`, `/my/schedule`,`/my/shows`, `/my/movies` (2×2 on a phone, 4 in a row from 40rem). For everyone: the text about the site (signed outplus a "Log in with ident" hint), then **Movies** (→ `/movies`) = the 10 latest RELEASED movies (release ≤ today) and**Shows** (→ `/shows`) = the 10 series whose newest released episode is newest. Tiles: poster (`posterUrlOf`:the file or `/posters/none`), title, year; a link to `/shows/<slug>` (movies too — same page, same Follow button).Phone: each row scrolls sideways inside itself (`tile-row`, the page never does); from 40rem a grid of 5.* **`/movies`, `/shows`** (`components/movies.hl`, `components/allshows.hl`, both compose `components/tilelist.hl`):every movie by `release` desc (future-dated ones first, undated last — "all … sorted by release date desc"); everyseries by its newest released episode (series with none released after them, by their own `release`). 24 per page,grid of 3 (phone) / 6. **The page is a path segment: `/movies/page/2`**, page 1 = `/movies`; out of range → the lastpage, not a number → page 1. NOT `?page=2` (the mission asked for it): a page component cannot read the query(hybriel#11, query half open) and hl:web's Back (`popstate`) drops the query — every page change would be a fullreload and Back would land on page 1. With the path, paging is a client-side navigation and Back works.* **Pagination** = components.hybriel's `components/pagination/pagination.hl`, **copied verbatim** to`components/pagination.hl` (4b4dabe) — re-copy, don't edit. It has no data out (its README, hybriel#87), so`tilelist.hl` wraps it in `pager-box { on click }`: a click on one of its buttons bubbles there, the button's text("Previous", "Next", a number; "…" nothing) gives the page → `navigate()`. Hidden when there is only one page.`styles.hl` makes its buttons a bit tighter on a phone so "Previous 1 … 99 100 101 … 326 Next" stays one row.* **`/my/movies`** (`components/mymovies.hl`): the movies the user follows, newest follow first (same rows as`/my/shows`: small poster, title with year). `/my/shows` now leaves movies out. Shared sort: `follows.hl``insertByFollowDesc`.* **Speed — `catalog.hl`**: built ONCE at start (one scan of shows + episodes: real data ~1 s, server answers~1.5 s after launch) into two sorted id lists in memory (`static index`; hand-written merge sort). A page onlyslices 24 ids and fetches those shows: real data, server time ≈ 5 ms per page. Kept fresh without a rebuild:`project.hl` `syncTick` calls `refreshCatalogShow(id)` after each synced show (its seasons/episodes only, ~2.5 ms;126 shows 0.3 s); a NEW DAY moves episodes that came out today (each series keeps its upcoming release dates) intoplace on the first read. `tools/sync-tmdb.hl` runs with the app stopped → the next start rebuilds.* **Content warning** (open, not decided here): the migrated movie table holds many adult titles (the old trackerimported TMDB lists); they show on the public `/` and `/movies`. No `adult` flag is stored.## What it does (ticket #17: season caret, skeleton rows, faster opening)* Caret DOWN while a season is closed, UP while open (`styles.hl` `'season-row.expanded season-caret'` rotate 180°).* **Opening** a season (`components/show.hl` `on toggleCollapse`): at once, client-side, the caret turns up andmin(episodes, 8) **skeleton rows** (`skeleton-row`: disc, number, title bar in the episode row's shape and height; CSSshimmer `@keyframes tracker-shimmer`) appear under it; then the face `showSeasonEpisodes(seasonId)` answers theseason's episodes `{ id, episodeNumber, title }` and `filledRows` replaces the skeletons. **Closing** is client-only.Opened and closed again before the answer → the answer is dropped. An error closes the season again.* Why it was slow: the old face `showRows` took `session`, and hl:web answers every session face by RE-MOUNTING the wholepage on the server and shipping every session-derived member as `sync` (`plugins/web/WebFramework.hl` `inbound`) —slug scan, watches scan, all rows twice. `showSeasonEpisodes` takes NO session (public data): no mount, no sync.* The watch state of the new rows comes from the member `watchedEpisodes` (`watchedEpisodesOf`: per season with awatched episode `{ all, except }` — `all` = mostly watched, `except` = the other ids), rendered with the page andre-synced after every watch face. Only this show's, never the user's watch list.* An ADULT title's page (followers only) still opens seasons through the session face `showRows(slug, collapsed)`:`showSeasonEpisodes` refuses seasons of adult titles (no user known). Real data has no adult title with seasons.* Real data (Daily Show S30 142 ep / LWT S12 30 ep, 390 + 1280): episodes visible 380–450 / 235–390 ms (outliers 1.1 s)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.## What it does (mission 054: adult titles, the backfill)* `shows.adult`: `true` / `false` from TMDB, `null` = not known yet (all 9,453 migrated rows). `shows.hl isPublicTitle` =`adult == false`. Used by `catalog.hl` (the homepage rows, `/movies`, `/shows`: built from public titles only;`refreshCatalogShow` adds or removes one) and `search.hl` (`titleOk[id]`; `dbSearch` skips the others — they are notcounted either). "Fetch from web" asks TMDB with `include_adult=false` and keeps only results with `adult: false`.* Not filtered: `/my/shows`, `/my/movies`, `/my/unwatched`, `/my/schedule` (the user's own follows).* The show page (mission 056, `components/show.hl adultHiddenFor`/`visibleShow`): a title with `adult == true` is "Notfound" for everyone who does not follow it (signed out too; no member carries its row); a follower sees it, and anUnfollow on the page hides it at once. Unknown (`null`) pages open (they are in no list). The faces `showRows` /`showToggle*` refuse a hidden title. Needs hybriel ≥ 73267707 for the instant hide (#122); `collapsed` derives from theslug, never from the session-derived `showRow` (else it snaps back after every session face).* Every TMDB answer sets the flag: the daily sync (`syncShow`/`syncMovie`), the search import (`importTitle`), the backfill.* **The backfill** (`project.hl backfillTick` → `tmdbsync.hl backfillTitle`): at start the app collects every title with`adult` unknown, a `tmdbId` and no `adultCheck`; then ONE title per step on the app's clock (0.1 s tick, shared with thedaily sync, which takes precedence — the backfill waits while a daily run is going on):`GET /movie|tv/<tmdbId>?append_to_response=external_ids` → `adult`, a missing `imdbId`/`tvdbId`, the poster (w342 into`posters/<oldId>.<ext>` when the file is missing — NOT for an adult title, mission 056; no TVmaze, no seasons). Then 260 ms pause per API request (≤ 40 /10 s; poster downloads go to TMDB's image host and are not counted). The lists and the search take the title at once.TMDB 404 → `adultCheck = 'TMDB 404'` (stays hidden, never asked again); no answer / 429 / 5xx → back of the queue, atmost 2 more tries per start. Persisted every 25 titles (mpackdb's `update` is durable anyway — the gate's resume checkstops it after 6 titles, before any persist). A restart builds the queue from what is still unknown = it resumes.* Log: `adult backfill: start, N titles without an adult flag`, every 500: `adult backfill: 500/9453 titles, adult=…notAdult=… unknown=… posters=… ids=… requests=… errors=… retries=… seconds=…`, each failure, then `adult backfill done: …`;with nothing to do: `adult backfill: nothing to do`.* 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),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 054.## Test```bashnode tests/browser.mjs # THE GATE: this app's own server + its OWN ident (a copy of# ident's code without .env, codes to a mail sink — no live# ident, no real mail; tests/identkit.mjs, same as calendar's# gate) + a real headless Chrome: signed out (header shows the# selector + "Log in with ident"; since #13 the homepage has# content, see below) -> sign in -> header shows "Log out" -> sign# out -> signed out again -> a reload stays signed out. Also# checks the header has no bottom border and filled buttons# (incl. the ident selector's) have none, while inverted ones# ("Log out") keep theirs.# tracker.worldapi.org#4: a fixture show (tests/seed-show.hl,# written into the gate's own storage before the server starts)# proves /shows/:slug — header renders, no checks signed out;# signed in, click an episode check (turns solid), click a# season check (itself AND its episodes turn solid, proven# after a reload — server-side, not just client state).# tracker.worldapi.org#5: the same fixture (now followed by the# test user) proves /unwatched — signed out, no rows; signed in,# only the two already-released episodes show, newest first, the# far-future one excluded; click a check, the row disappears# (proven server-side after a reload).# tracker.worldapi.org#6: the same fixture proves /schedule —# signed out, no rows; signed in, only the far-future episode# shows (the two already-released ones excluded), no check icons# at all, unaffected by any watch toggle on the other pages.# tracker.worldapi.org#7: the fixture now has a 2nd followed# show (followed later, no poster name, never watched) and# proves /my/shows — signed out, the sign-in message; signed# in, newest follow first, title links, loaded posters (the# placeholder), S01E02 after /unwatched's click, then S02E01# (highest, not most recent) after the show-page checks; the# unwatched show shows no episode.# tracker.worldapi.org#8: the lists moved to /my/unwatched and# /my/schedule (old addresses: 301, and the browser lands on# the new one), rows say S01E02 / S02E01, titles carry the# year ("Gate Test Show (2024)", "Unwatched Gate Show (2025)"# — from `release`, that show has no `year`), the show page# says "1 episode" / "2 episodes", /my/shows gives the show# with a poster FILE (the gate writes posters/sh1.png) its# own URL and the other one /posters/none.# tracker.worldapi.org#9: a FAKE TMDB (tests/faketmdb.mjs, a# node http server inside the gate, fixed JSON + a png; the# app/tool get TMDB_BASE_URL/TMDB_IMAGE_URL + a gate token —# never the real TMDB). App stopped → tools/sync-tmdb.hl: +2# seasons, +4 episodes, 3 fixture episodes matched by S/E and# updated, 2 posters, no season 0, Bearer token on every call;# a 2nd run changes nothing. Restarted: /my/schedule and# /my/unwatched show the new episodes, /my/shows + the show# page the downloaded posters (/posters/sh2.png byte-equal),# S1 keeps its watched episodes, no duplicates. Then the# app's OWN daily run (TRACKER_SYNC_HOUR = the current UTC# hour, fake TMDB slowed to 700 ms/request, one new episode):# it runs, finds the episode, and pages asked meanwhile are# answered < 2.5 s (between shows) though the run takes ≥ 3 s.# tracker.worldapi.org#10: the PWA — head links (manifest,# apple-touch-icon, favicon, theme-color = token darker), the# manifest (type, name, start_url, scope, standalone, colours),# every icon a real PNG of its size, touch icon 180, favicon# svg + ico; Chrome: no installability error, manifest parsed# without errors; the worker /__hl/sw.js registered, scope /,# controls the page (a separate 390 px phone tab). OFFLINE:# CDP offline → the note appears; the server STOPPED too (CDP's# offline does not reach the worker's own fetches) → reload of# / shows header + note from the cache; /my/shows gives# "Unavailable offline"; server back → no note.# mission 047: a CLIENT-SIDE navigation /my/shows → the show# (window marker proves no reload): every check/caret svg in the# SVG namespace and drawn — again after expand, an episode click,# collapse (FAILED on the old vendored build: XHTML namespace,# circle width 0 — /tmp/w047/old-build-gate.log); caret down/# right and flips; episode check under the season check; artists# rgb(206,145,120); unwatched icon = accent outline + accent# check; Follow: "Following" filled → unfollow ("Follow",# outlined) → gone from /my/shows, survives reload → follow →# top of /my/shows; signed out: checks + Follow visible, a click# (episode, season, Follow) opens the sign-in modal, Close# closes it, a reload shows nothing was written. 390 px + 1280# px: header one row, no sideways scroll, show-page order# (phone: title, full-width poster, genres, plot, cast).# tracker.worldapi.org#12: link icons only for existing ids# (show 1: TMDB+IMDb, show 2: TMDB+TVDB, a fixture MOVIE: TMDB# /movie/), exact hrefs, target _blank + rel noopener, a click# opens a NEW tab (CDP target, canAccessOpener false) and the# page stays; also after client-side navigation; phone/desktop# position. Sync: external_ids come with the details request,# the movie only via /movie/, a FAKE TVmaze (same fake server,# /tvmaze/…, 301 like the real one): imdb hit, imdb 404 → thetvdb# hit; afterwards the stored ids are unchanged (tt0000001, 4002# — TMDB says otherwise) and the missing ones filled; 2nd run:# ids=0, no TVmaze request.# tracker.worldapi.org#13: fixture + 32 never-followed movies# (Pager Movie 01…30 = 2001-01-01…30, one 2099, one undated) and# a series with an episode 2021-01-01. Home signed out: no# icons, the text, headings → /movies /shows, the 10 latest# RELEASED movies (30…21, not the 2099 one), the series by# newest released episode; signed in: 4 icons → /my/…, the# Movies icon → /my/movies (client-side). /movies via the# heading: page 1 = 2099 + 30…08, "1 2 Next"; Next →# /movies/page/2 (client-side, 9 left, undated last), "1" →# /movies, Back → page 2, direct load, page 99 → 2, abc → 1.# /shows: series only, order, no pagination for one page; a# tile → show page. Follow a MOVIE on its page → /my/movies# has it, /my/shows does not. After the sync tool + restart# /shows reorders (S03E01); after the IN-APP run (TMDB adds a# released episode to show 2) /shows and the home row put# show 2 first without a restart. Offline / from the cache# still has the text and both rows.# Session-sync (hybriel 64527baa): after each face-driven change# (/my/unwatched click, expand, episode click, Follow) every row# appears exactly once.# tracker.worldapi.org#15: the fake TVmaze also serves# /tvmaze/shows/<id>/episodes. First runs: lists agree with# TMDB → nothing taken. Then TVmaze knows more: S3E4's title +# date filled (TMDB "Episode 4"), S3E5 + a new S4 added, TMDB's# S3E1 title kept, show 2 numbered differently → skipped;# idempotent; /my/schedule + show page show them; TMDB catches# up → adopts the rows by number (no duplicates, TMDB wins).# tracker.worldapi.org#14: the header magnifier → /search# (client-side); typing: 1 letter → "at least 2", "gate" →# 3 titles in order with year/type/link, address /search/gate;# people ("test act"); case/accents ("GATE tést"); nothing →# note + Fetch; reload of the address = same results; bad %# escapes → 200; "/" → space in the address. Fetch from web# (fake TMDB search/multi): ours (tv 1001, movie 1003) and the# person left out, w92 thumbs; signed out Add → modal, nothing# sent. Signed in: Add tv 1006 (details 404) → error, every# list unchanged (hybriel#121/#122); Add tv 1004 → its page# with season/episodes/poster/genre/4 id links, exact TMDB# calls; then in our results and gone from the web list; Add# movie 1005 → /movie/ only. Screenshots search-{390,1280},# search-web-{390,1280}, search-signed-out-modal-1280.# Merge #13 + #14: an imported title is on /movies and /shows# at once (search.hl → catalog.hl refreshCatalogShow); #13's# 32 extra fixture titles are 'Pager …' so 'gate' finds only #14's.# Mission 054 (adult titles): tests/seed-adult.hl adds, app# stopped, 1 adult movie alice follows + 14 titles with an# unknown flag (fake TMDB: 1 adult, 1 404). Backfill off:# none on /, /movies (both pages), /shows, the search; the# adult one on alice's /my/movies + its page; Fetch from web# drops TMDB's adult result. Backfill on (fake TMDB 300 ms per# answer): stopped after 6 titles, restarted → only the rest is# asked (resume), pages < 1.2 s meanwhile, then the lists and# the search have the non-adult ones at once, the poster is# served, a 3rd start has nothing to do. Screenshot# adult-home-1280.png. startServer() runs TRACKER_BACKFILL=0# unless a block switches it on.# tracker.worldapi.org#16: person pages (fake TMDB /3/person/<id># + combined_credits, w185 photo → fake.personRequests): a# COMPLETE person at once (facts, rows, TMDB not asked); the# cast link on the show page → /person/Test-Actor CLIENT-SIDE:# "Loading filmography…" (TMDB slowed to 900 ms), fills without# reload: facts + photo, 86 rows (87 titles − adult), order,# roles merged, posters (file / TMDB w92 / placeholder), ONE# person request + photo, log steps=4 added=85; bio four lines# → click → all; row → show page → Back; an added title's page;# second visit: complete, nothing asked/added; Second Actor:# linked, added=0, our adult record hidden though TMDB's credit# is not; search finds the added title once; 390/1280 no# sideways scroll; screenshots person{,-complete}-{390,1280}.# Mission 056: signed-out header ONE row at 320/390/1280# ("Log in" on a phone, "Log in with ident" wide; shots# header-signed-out-{320,390,1280}.png); the backfill does not# download the adult title's poster; /shows/adult-gate-movie is# "Not found" signed out (no title in the HTML), alice sees it,# her Unfollow on the page hides it at once (+ reload,# /my/movies); an episode check on a normal show page toggles# and back. Every stopServer() leaves the tab on about:blank first# (else websocket reconnect errors fail the console check).# #17: caret up (open) / down (closed) and flips; opening with the# face held back 1.5 s (`window.__delayFace`, an init script that# delays that websocket frame): 2 skeleton rows under season 1,# episode-row shape/height, shimmer animation, caret already up,# no episode yet (390 + 1280, shots show-skeleton-*); after the# answer: skeleton gone, every row once (hybriel#121), watch state# right (show-open-*, show-closed-*); closed while loading stays# closed; only showSeasonEpisodes is sent (wsSent), closing sends# nothing.# #26: Person Film One (opened from Test Actor's filmography,# client-side, face held back 1.5 s): skeleton (poster, cast# line), TMDB not asked yet → complete without reload: poster,# IMDb, cast (Test Actor linked, a NEW person, adult person left# out); ONE details request; reload complete; the new person's# page. Person Series: 3 skeleton season rows → in 4 steps (gate:# TRACKER_DETAILS_PART_EPISODES=1, a season per step) 2 seasons# with TMDB + TVmaze episodes, 4 links, cast from aggregate_# credits; open/close; the search finds the new person. Repair job (fake TMDB# 60 ms, TRACKER_REPAIR_FAST): 82 titles, stopped after 6,# restart resumes, 404s marked, adult never asked, /movies < 1.2 s# meanwhile, a 3rd start has nothing to do. Shots# title-skeleton-1280, series-skeleton-1280.# #28: the sync tool stores the credits from its details request# (show 1: cast 23 / crew 7 / +27 people; the movie 3 / 13 / +15;# the 2nd run adds nobody). Movie page: TMDB cast order with the# characters, crew by job (a director listed twice: once; 9# writers → 6 + "Show all crew (13)", client-side, no face).# Series page: 23 of all seasons by episodes (Test Actor linked,# not added twice, adult left out), the later-season actor,# collapsed to 20 + "Show all (23)" (no comma after the 20th) →# all + "Guest stars" (no face sent), "Show less"; crew Created# by / Directed by (by episodes) / Written by / Story / Music; a# new person's page; the completed #26 title's crew; the search# import's cast + crew. Credits job (fake TMDB 60 ms,# TRACKER_CREDITS_FAST): TRACKER_CREDITS_MAX_MB=1 pauses it before# the first title; stopped after 6, resumes, done, /movies# < 1.2 s meanwhile, a done title's cast + crew, a 3rd start has# nothing to do. Shots credits-movie-1280, credits-series-# {collapsed,all}-390, credits-series-1280.# tracker.worldapi.org#18: the first start clears the seed# movie's copied summary (marker file); the first in-app run is# the full walk and stores its day. At the end: fake change# lists (2 ids per page) + TVmaze updates, day = yesterday →# the in-app run asks 2+2 pages + TVmaze, syncs ONLY 1001,# the movie (followed) and Person Series (light: details +# season/2 only), never 1002/1004; renamed titles on their old# slugs, the creator's summary kept, the movie TMDB's new text,# Person Series' new episode, search by the new name; records# via tests/peek-shows.hl (tmdbSummary, tagline, tvmazeSummary# HTML-stripped, untouched tmdbSync); a 503 change list → full# walk, day kept; 20 days → full walk; TVmaze text shown when# there is no other. Shots delta-{show,movie}-1280.# 261 checks (#13 + #14 + #15 + 1 merge check + 17 mission 054 + #16: 19 + 5 mission 056 + 6 #17 + 17 #26 + 18 #28 + 12 #18). Own servers :8700 (this app) / :8701 (ident copy)# / :8710 (fake TMDB); own storage .scratch/gate-store; Chrome# on 8702-8709. Other ports (workers get 8720-8739):# TRACKER_GATE_PORT=8720 TRACKER_GATE_IDENT_PORT=8721 TRACKER_GATE_CHROME=8722-8726 TRACKER_GATE_TMDB_PORT=8727 node tests/browser.mjs# TRACKER_GATE_SHOTS=/tmp/x also writes {home,show,my-shows,# my-unwatched,my-schedule,movies,movies-page-2,shows-list,# my-movies}-{390,1280}.png, signed-out-modal-# 1280.png, synced-*.png (after the sync, 1280×900; synced-show-390# + synced-movie-390 at 390) + pwa-phone-# online/offline.png (390 px, dpr 2), show-{skeleton,open,closed}-# {390,1280}.png (#17) — LOOK at themps -eo pid,args | grep [h]l-browser-tier # must print nothing afterwards```## Deploy (Byrodin)Target: `/CONTAINERS/projects/tracker.worldapi.org` on Byrodin, container`tracker.worldapi.org` (`docker-compose.yml`: debian:12-slim, host network,`HL_HOST=127.0.0.1`, `TRACKER_PORT=45008`, `TRACKER_WATCH=0`, the folder mounted at`/home/tracker`, `./bin/hybriel project.hl`), public https://tracker.worldapi.org/ vianginx (TLS ends there; no baseUrl/tls in the app, like ident/notes).* **First deploy: done by the architect** (folder, nginx vhost with WebSocket Upgradeheaders, cert, DNS, **and registering this app in the live ident** — `/apps` → name +origin `https://tracker.worldapi.org` → the API key + secret go into `.env` next to`docker-compose.yml`, `TRACKER_KEY=… TRACKER_SECRET=…`).* **Later: `./deploy.sh`** on Loreana, in this folder: runs the gate (refuses on a failure;`--skip-tests` skips it LOUDLY), backs up `storage/`/`.sessions/`/`.env` (what exists) toLoreana's `/media/SLOW1TB2/deploy-backups/<app>/` (newest 5 kept; an empty/failed backup stopsthe deploy), then rsyncs the code to`[email protected]:/CONTAINERS/projects/tracker.worldapi.org` (never `storage/`,`.sessions/`, `.env`, `.scratch/`, `server.*`, logs — the preview is checked for them; no`--delete`), `docker compose up -d && docker compose restart` over `ssh -F /dev/null`,then waits for https://tracker.worldapi.org/ to answer 200.* `./deploy.sh --dry-run` = the gate + `rsync -n` + the commands it would run (no restart, noURL check). `--target DIR|HOST:DIR` and `--url URL` point it elsewhere (tested against alocal directory, see STATUS.md).## Data`storage/mpackdb/`: `users.db` (`identity` → this app's own user id, `users.hl`) plus, sincestep 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),`seasons.db` (6430), `episodes.db` (231584), `follows.db` (128), `watches.db` (11692), all withfresh mpackdb ids and every reference re-pointed (`tools/migrate.hl`); the one old user is thisapp's user for ident short id `az5b2`. Since step 9 the TMDB sync adds seasons/episodes and`storage/mpackdb/posters/<oldId>.<ext>` (w342, ~50 KB each), and sets `tmdbSync`/`tmdbPoster` onshows. #18: `shows.tvmazeSummary` (TVmaze's text, HTML stripped), `summary` = the creator's own text only;`sync-state.txt` (the day of the last finished daily run), `summaries-cleared.txt` (the one-time clear ran: counts). Mission 054: `shows.adult` (true/false/null = unknown → hidden from lists + search) and `shows.adultCheck` (why thebackfill gave up on a title, e.g. `TMDB 404`). #26: `shows.minimal` (added by a filmography), `detailsAt` (ms, complete),`detailsCheck` (`TMDB 404` / `adult`: never asked again), `detailsError` (a TVmaze/poster failure while completing); people addedfrom a cast: `oldId tmdb-person-<id>`, `added` (ms). Known in the migrated data (not caused by the sync): 12 duplicate episode tmdbIds and 1episode whose `show`/`season` disagrees among the followed shows. Counts and the referential-integrity proof:`STATUS.md` "ticket #2". Real data 2026-10-01: 7,814 movies + 1,639 series (`shows.type`), 232,384 episodes (935series have a released one).## Files| File | ||---|---|| `CONCEPT.md` | the creator's concept — do not edit || `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), `/person/:slug` + `/profiles/:name` (#16), `/` component Home), the app's own session cookie; #14: builds the search index at boot (`ensureIndex`); mission 054: the adult backfill (`backfillTick`) on the clock it shares with the daily run || `users.hl` | the ident exchange + the `usersTable`, copied from calendar.worldapi.org's `users.hl` || `login.js` | bridge between `<ident-selector>`'s `ident-login` event and the shell, copied verbatim from calendar.worldapi.org || `components/home.hl` | `/` (#13): signed in the 4 icon tiles; the text; the latest 10 movies and series (catalog.hl) || `components/movies.hl`, `components/allshows.hl` | `/movies` (+ `/movies/page/:page`), `/shows` (+ `/shows/page/:page`): every movie / series, 24 per page (#13) || `components/tilelist.hl` | the poster grid + pagination both lists compose; turns a pagination click into `navigate()` (#13) || `components/pagination.hl` | the pagination, copied verbatim from components.hybriel.worldapi.org (`components/pagination/pagination.hl`, 4b4dabe) — re-copy, don't edit || `components/mymovies.hl` | `/my/movies`: followed movies, newest follow first (#13) || `catalog.hl` | the public lists: built once at start, refreshed per synced show and per new day (#13); only `adult == false` titles (mission 054) || `components/main.hl` | the shell (header: brand, `<ident-selector>`, log in / log out; the offline note + its `netProbe` tick, #10) || `components/loginfailed.hl` | `/login/failed`: why a login didn't work || `components/show.hl` | `/shows/:slug`: the show page (header, Follow toggle, seasons with caret, episodes, watch check; signed out the sign-in modal) || `components/modal.hl` | the modal dialog, copied verbatim from components.hybriel.worldapi.org (`components/modal/modal.hl`, becbd59) — re-copy, don't edit || `components/unwatched.hl` | `/my/unwatched`: unwatched, already-released episodes of followed shows, newest first || `components/schedule.hl` | `/my/schedule`: not-yet-released episodes of followed shows, soonest first, no checks || `components/myshows.hl` | `/my/shows`: followed SERIES (#13: movies → `/my/movies`), newest follow first — small poster, title, last watched `SxxEyy` || `shows.hl` | read-only data access (shows/seasons/episodes/genres/persons; #28: `showBySlug` via a slug → id map; #18: `summaryOf`), plus the list labels `episodeCode`/`titleWithYear`/`posterUrlOf` (#8), reused by the `/my/` pages; `externalLinksOf` (#12: the link icons); `isPublicTitle` (mission 054: `adult == false`) || `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) || `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 `clearCopiedSummaries` || `tests/peek-shows.hl` | #18: the gate reads a title's description fields (`TRACKER_PEEK`), or clears summary + tmdbSummary of one (`TRACKER_CLEAR_TEXTS`) — app stopped || `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) || `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 054: `adultOf`, the adult backfill (`adultBackfillQueue`, `backfillTitle`) || `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` || `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)") || `styles.hl` | all CSS (imports the tokens from `shared/tokens.hl`; accent green `#4ec9b0`) || `shared/tokens.hl` | the WorldAPI tokens, vendored verbatim from `ident.worldapi.org/shared/tokens.hl` || `tests/browser.mjs` | the gate (above) || `tests/seed-adult.hl` | mission 054: the gate's adult/unknown-flag titles + alice's follow of the adult one, written with the app stopped || `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 || `tests/identkit.mjs` | starts a throwaway ident copy for the gate, copied from calendar.worldapi.org's `tests/` || `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` || `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) || `search.hl` | #14: the search — in-memory index (`ensureIndex`, `dbSearch`), `queryOfParam`, TMDB `webSearch`, `importTitle` (new record + `syncShow`); #18: `titleIdByTvmaze`, `searchTitleRenamed` (stale entries skipped) || `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) || `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) || `components/person.hl` | #16: `/person/<slug>` — photo, facts, bio, filmography; "Loading filmography…" + the fill without reload || `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 || `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 || `tests/realdata-056.mjs` | mission 056, NOT the gate: the merged app on a COPY of live `storage/` after the adult backfill (STATUS.md mission 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 || `tools/count-external-ids.hl` | #12: how many shows have each external id, per type, all / followed — ONLY on a copy || `tools/count-duplicate-ids.hl` | duplicate `@id`s per table — ONLY on a copy (hybriel#113 check, mission 047) || `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 || `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`; #18: `withDetailsFields` (the record's fields, shared with the completion), `applySynced` (the daily sync: fields + credits) || `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 || `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 || `tools/check-public-slugs.hl` | mission 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 || `tests/cdp.mjs`, `tests/ports.mjs` | the CDP browser driver, copied from `ident.worldapi.org/tests/` || `docker-compose.yml`, `Dockerfile`, `deploy.sh` | Byrodin container; the deploy from Loreana (section "Deploy") || `tools/migrate.hl` | one-off: old MongoDB export → `storage/mpackdb/` (step 2, idempotent, see `STATUS.md`) || `tools/verify.hl` | one-off: proves the migrated data against a COPY of `storage/mpackdb/` (counts, zero dangling references, one show end to end) || `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 || `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)" |## Vendored Hybriel`bin/hybriel` + `plugins/` = **hybriel master 8590df63** (mission 056; includes #122 18cf2fb9 — the post-facesession sync re-derives with the page's route params — and #121 426944c7, on top of ff51cf46's #113/#115/#116/#118,the client-built SVG fix, hl:markdown), `bin/hybriel` sha256`ca830ef8400578ee081347953258acbfc8b516942d411e45aa98cffc77efdd6d`. No local patch. Built read-only from an archive(never in the hybriel checkout; the same with `hybriel-047` → `hybriel-056` for the older build):```bashgit -C /media/STORAGE/projects/hybriel archive master | tar x -C .scratch/hybriel-047cd .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)cp zig-out/bin/hybriel ../../../bin/hybriel# plugins: copy every plugin dir the app already has (core crypto data fetch fs http http1 mpackdb proc smtp# time web) from .scratch/hybriel-047/plugins/ over plugins/ — `diff -rq plugins .scratch/hybriel-047/plugins`# must then list only plugins the app does not use```The previous builds are kept in `.scratch/pre-056/` (ff51cf46, sha256 `55a724a0…`) and `.scratch/pre-047/`(837fe120 via ident, sha256 `9e5e95b3…`), bin/ + plugins/ each. After a re-vendor run the gate.
Branches
- mainmain branch
Latest commits
- f83571c3tracker#18 (mission 067): daily sync by change lists — TMDB /tv|movie/changes (since the stored day, paged) + TVmaze /updates/shows → only our changed titles (followed: full step, unfollowed: light step — changed seasons, no TVmaze), full walk on first run / gap > 14 days / failed list; show record refreshed (title, tmdbSummary, tagline, status, genres …; renamed titles re-indexed); summary = the creator's own text (page: summary > tmdbSummary > tvmazeSummary), one-time clear of copied summaries (9,647 on the live copy); gate 261, tests/realdata-018*.mjs, README + STATUSmre
- 10bb3f93tracker#28 (mission 061): full cast (all seasons, main cast by episodes, guest stars) + crew (created by, directed by, written by, screenplay, story, music) — stored by the details completion, the daily sync, the search import (one details request) and a background credits job (resumes, RSS limit); show page collapsed after 20 with client-side Show all; showBySlug via a slug map; gate 249, tests/realdata-028.mjs, README + STATUSmre
- 25a50bc4tracker#26 (mission 059): titles from a filmography are completed — on open (skeleton, step-wise face showComplete, no reload) and by the in-app details repair (resumes, TMDB-paced, series in parts); cast from TMDB credits; gate 231, tests/realdata-026.mjs, README + STATUSmre
- 1704ec45tracker#17 (mission 057): season caret down/up, skeleton rows while a season loads, sessionless showSeasonEpisodes face (no page re-mount), client-only close; gate 214, tests/realdata-057.mjs, README + STATUSmre
- f2fe3e36mission 056: README + STATUS (merge, fixes, Hybriel 8590df63, real-data check), tests/realdata-056.mjs, tools/check-public-slugs.hlmre
- f40c250emission 056: re-vendor hybriel master 8590df63 (#121, #122); an adult title's page is Not found for non-followers; gate: leave the page before stopping the servermre
- 2b7fdd6cmission 056: signed-out header one row on phones ("Log in", nowrap), backfill skips adult titles' posters, gate checksmre
- 2c53d5efMerge branch 't16-person' (tracker#16 person pages) into main; filmography shows only public titles (054 adult flag), gate race fix (backfill start line)mre
- c171227emission 054: hide adult/unknown titles from the public lists and the search; in-app adult-flag backfill (TMDB details + poster per title, resumes), gate + real-data proofmre
- 139fafd8tracker#16: short bio (4 lines, click = all), real-data check script, README + STATUSmre
- 93be9476tracker#16: person pages /person/<slug> with the filmography fetched from TMDB on the first visit (step by step), gatemre
- 47a3cae6STATUS: mission 053 merge commit idsmre
- dcc5eecaMerge branch 't14-search'mre
- 03edc783Merge branch 't15-tvmaze'mre
- 71b46345tracker#15: numbering check by date or title, placeholder titles in other languages, docs + real-data proofmre
- 6bb2daf1tracker#13: homepage (tiles, intro, latest movies/shows), /shows, /movies/page/N, /my/movies; lists cached in memorymre
- b8bd1157tracker#14: README + STATUS (search, real-data numbers, gate, merge notes)mre
- 65c694a8tracker#14: search — header magnifier, /search/<text> (in-memory word-prefix index over titles + people), Fetch from web (TMDB search/multi, ours left out), Add = import via syncShow; gate +25 checks, real-data scriptmre
- 34f2c15btracker#15: TVmaze merge in the sync (gaps only: new episodes/seasons, empty titles/air dates; numbering check), fake TVmaze episodes + gatemre
- cbdc4ea7tracker#12: link icons TMDB/IMDb/TVDB/TVmaze; sync fills missing ids (TVmaze lookup); movies fetched via /movie/mre