tracker
All repositories: gitoria
86.3 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).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) || `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).* **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 (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.# 249 checks (#13 + #14 + #15 + 1 merge check + 17 mission 054 + #16: 19 + 5 mission 056 + 6 #17 + 17 #26 + 18 #28). 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. 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), 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) || `tmdbsync.hl` | the TMDB sync of one show (`syncShow`: seasons, episodes, poster; #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` || `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`) || `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 sync/import), the credits job's `creditsQueue`/`creditsStep` || `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`; 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
- 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
- b105bcd8tracker#11: Hybriel master ff51cf46 (checks no longer vanish), mobile-first styles, carets, follow button, sign-in modal, inverted check, orange castmre