gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Address
https://tracker.gitoria.worldapi.org/
Owner
Caramboleyo
Created

tracker.worldapi.org

The new tracker: a Hybriel app that will track the TV shows (and later movies) the creator follows 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 (an identity selector, like calendar/gitoria) and sign-out; the homepage itself stays empty. No design carried over from the old app.

Step 2 (ticket #2): the old tracker's MongoDB export is now in storage/mpackdb/ (new mpackdb ids, every reference re-pointed) — see "Data" and STATUS.md. Nothing is shown in the UI 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 its episodes, 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 episode of 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 user follows, newest follow first: small poster, title, last watched episode (S08E35) (see "What it does (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, show titles in lists with the year (Doctor Who (2005)), "1 episode" singular, and every /my/ page under 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 green on 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 and TVmaze (a movie: TMDB + IMDb), only for the ids the show has, each in a new tab; the TMDB sync fills missing ids (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 10 series 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 not yet — 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 and people at once (in-memory index), "Fetch from web" lists TMDB titles we don't have yet, "Add" imports one like the daily sync and opens its page (signed out: the sign-in modal) (see "What it does (ticket #14)" below).

Mission 010 (old 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 the user's /my/ pages and its page. A background job in the app (the "adult backfill") asks TMDB once per title for the adult flag, the poster and the external ids (see "What it does (mission 010 (old 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 is fetched 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).

Mission 016 (old 060) (ticket #27): every movie, series and person has a 5-character short id (shortId, like a9s9a) shown under its poster / photo; the 702 titles the old tracker numbered keep theirs (100jh…); /<shortId> answers 301 to the page (see "What it does (mission 016 (old 060))" below).

Ticket #26: titles a person page adds from a filmography (#16: minimal records) are completed — opening one shows skeletons and fills 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 the details 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 the titles 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). Mission 022 (old 068) (tickets #20, #21): every page heading says its type first, in the type's colour, then the name in white ("Series | Reacher", "Movie | …", "Show | …", "Genre | Action", "Person | …"). TV titles are split into Series (scripted, mini-series, documentary) under /series and Shows (reality, talk, news, video) under /shows; movies live at /movies/<slug>. The old /shows/<slug> of a series or movie answers 301. Home, search, /my/series + /my/shows follow the split. A background job asks TMDB once per TV title for its type (details: [docs/kinds.md](docs/kinds.md)).

Ticket #19: franchises and timelines — /franchises, /franchises/<slug>, /timelines/<slug>, a ‹ Prequel | Timeline | Sequel › widget on title pages, the creator's editor, and a background seed from TMDB movie collections (see "What it does (ticket #19)" below, [docs/franchises.md](docs/franchises.md)).

Ticket #40 (mission 034): the big franchises are filled — 17 franchises, 31 timelines, movies AND series, from the hand-written data file data/franchises.json (TMDB ids, in-universe or release order); a background job imports the missing titles (the search's import, complete) and builds them once after the other start jobs; the creator's edits are never overwritten (see "What it does (mission 034)" below, [docs/franchises.md](docs/franchises.md)).

Mission 023 (old 070) (2026-10-02): #18, #20/#21 and #19 merged into main; the sync never destroys migrated data (copied summaries are MOVED to migratedSummary, a replaced title/genres/homepage/tagline is kept in migrated*); Hybriel re-vendored to 8efba065 (memory fix #126) with & on read-only lambda parameters (#48); HL_GC_BYTES stays Hybriel's default (see "Deploy" and "Vendored Hybriel" below, STATUS.md mission 023 (old 070)).

Mission 024 (old 071) (2026-10-02): Hybriel master 190aa11d (#127 both shapes) measured on the real copy against the live binary — NOT adopted (after the first-start jobs /my/series 2.4× slower, RSS not flat); the vendor stays 8efba065. The build is ready in .scratch/w071/vendor-190aa11d/; numbers and the open decision: STATUS.md mission 024 (old 071).

Mission 028 (code order, 2026-10-03): no behaviour change — every .hl except project.hl moved into lib/ (one file per topic, helpers beside it, shared ones once in lib/util.hl) or components/; project.hl is the map (config, routes, wiring, an index of which file does what); the background jobs are lib/jobs.hl; let only where a variable is reassigned (see "Code order" and "Files" below, STATUS.md mission 028).

Missions 029 + 030 (2026-10-04): tracker#33 — every title TMDB's details never went through (the old tracker's migrated rows: 5,834 on the live copy) counts as incomplete and is completed (repair job / on open); a title without a TMDB poster shows the placeholder. tracker#34 — Follow under the poster, as wide. tracker#35 — the status pill next to a series' title, TVmaze's status preferred (see "What it does (missions 029 + 030)", STATUS.md).

Mission 031 (2026-10-04, tracker#32): the app wears the WorldAPI theme (theme/, vendored from layouts.worldapi.org — never edited here) and its header: desktop brand · search field · Series | Shows | Movies | Genres | People · user icon (its dropdown: the /my/ lists, Settings, Logout); signed out the ident selector in the icon's place; phone ☰ (sidebar overlay with the main menu) · brand · loupe · user icon / the ident icon. New pages /genres, /people, /settings; borderless except the season foldables (node theme/check-theme.mjs . = 0, a deploy gate) (see "What it does (mission 031)").

Mission 032 (2026-10-04, tracker#37): /people lists everyone, the one updated last first, as photo + name tiles with the pagination (no letters any more); the search shows people with their photo; /settings is only the heading "Settings" (see "What it does (mission 032)"). tracker#39 (same mission): double episodes merged, the sync no longer makes them (see "What it does (tracker#39)").

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)

cd /media/STORAGE/projects/tracker.worldapi.org
TRACKER_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 in project.hl, unlike ident's SMTP settings — set these in the shell or docker-compose.yml):
VariableDefault
TRACKER_PORT45008
TRACKER_URLhttp://127.0.0.1:<port>this app's own origin — the ident login button's return= is built from it
IDENT_URLhttps://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_WATCHon0 = no dev watcher (the container)
TRACKER_STORAGE./storage/mpackdbthe usersTable directory
TRACKER_SESSIONS./.sessions (hl:web's own default)this app's own session store
HL_HOST (or HOST)0.0.0.0interface 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_SYNCon0 = no daily TMDB run inside the app
TRACKER_SYNC_HOUR4UTC 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_URLhttps://api.themoviedb.org/3 / https://image.tmdb.org/t/pthe gate points both at its fake TMDB
TVMAZE_BASE_URLhttps://api.tvmaze.com#12: the TVmaze id lookup; the gate points it at its fake (<fake>/tvmaze)
TRACKER_BACKFILLonmission 010 (old 054): 0 = no adult-flag backfill (off too without TMDB_READ_TOKEN). A dev server with the real token runs it — it only writes the storage it was given, but asks real TMDB
TRACKER_BACKFILL_LOG_EVERY500a progress line every N titles
TRACKER_OLD_SHORT_IDS./data/old-short-ids.jsonmission 016 (old 060): the old tracker's short ids (oldId → id), part of the code (deployed with it)
TRACKER_SHORTIDSonmission 016 (old 060): 0 = no short-id backfill (the gate's resume check; ids for NEW records are still given)
TRACKER_SHORTID_BUDGET_MS40mission 016 (old 060): ms of work per backfill batch (one batch per 0.1 s tick)
TRACKER_SHORTID_LOG_EVERY5000mission 016 (old 060): a progress line every N records
TRACKER_REPAIRon#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_EVERY100a progress line every N titles
TRACKER_REPAIR_FASToff1 = 20 ms pause between titles instead of the TMDB pacing — the gate only (fake TMDB)
TRACKER_CREDITSon#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_EVERY500a progress line every N titles
TRACKER_CREDITS_FASToff1 = 20 ms pause between titles — the gate only (fake TMDB)
TRACKER_CREDITS_MAX_MB16000#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_MAX300#28: at most this many cast per title (TMDB's order) — a talk show / soap lists 3,000+ one-episode guests
TRACKER_KINDSonmission 022 (old 068): 0 = no kind backfill (TMDB's TV type per title; off too without TMDB_READ_TOKEN). A dev server with the real token runs it (~1,600 requests on the real data)
TRACKER_KINDS_LOG_EVERY500mission 022 (old 068): a progress line every N titles
HL_GC_BYTES64 MiB (Hybriel's default — not set in docker-compose.yml)mission 023 (old 070): the runtime also collects garbage after this many bytes of plugin data (#126). Smaller = less memory, slower big pages; 0 = only by count (the old binary's memory). Real copy, 200 page loads after the jobs: default → /my/unwatched 4.8 s, RSS 2.1 → 2.8 GB; 256 MiB → 2.2 s, 2.7 → 3.5 GB but the first-start jobs grew to 12+ GB (the old binary: 15 GB); 0 → 1.2 s, 6.2 → 8.7 GB
TRACKER_COLLECTIONSon#19: 0 = no TMDB collection seed (off too without TMDB_READ_TOKEN; the main gate sets 0). A dev server with the real token runs it against the storage it was given
TRACKER_COLLECTIONS_LOG_EVERY / TRACKER_COLLECTIONS_PERSIST_EVERY500 / 25#19: a progress line / a persist every N movies
TRACKER_FRANCHISE_SEEDon#40: 0 = no curated franchise seed (the other gates set 0). Without TMDB_READ_TOKEN it only builds from the titles we have. A dev server with the real token imports into the storage it was given
TRACKER_FRANCHISE_FILE./data/franchises.json#40: the curated franchises (data, deployed with the code; the gate writes its own)
TRACKER_FRANCHISE_SEED_LOG_EVERY25#40: a progress line every N imports
TRACKER_EDITORSaz5b2#19: ident ids (comma list) allowed to edit franchises and timelines (the gate sets its own)

Without TRACKER_KEY/TRACKER_SECRET, / still renders (signed out, the selector and "Log in with ident" show in the header); a login attempt answers 'login is not set up on this server (TRACKER_KEY / TRACKER_SECRET missing)' instead of exchanging a code — so the app is never 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 centered sign-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 identity selector (<ident-selector>, ident's /selector.js) sits in the header (components/main.hl, userBox), a plain "Log in with ident" link beside it when signed out. 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 for externals in general") hands the code to a hidden input, whose change calls the server face 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 makes or 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 (the login button's return URL) for a client that has no socket yet. "Log out" in the header calls 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 on the 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 — no display name, no other per-user data yet). Step 2 (tracker.worldapi.org#2, open) brings the old 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 brings real ones back), title, genre pills (blue, link to /genre/<slug>) and the plot summary to its 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; each row: a round check icon (a disc with a check, drawn in SVG/CSS, no icon font), the season title/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 an episode's check toggles that one watch; clicking a season's check toggles the season's own watch AND every one of its episodes at once (watches.hl setSeasonWatched). Every click is a server round trip (emit server showToggleEpisode / showToggleSeason; collapse/expand: see ticket #17 below) that rebuilds the row list server-side and reassigns it whole — a season has at 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 calls an 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 to declare such helpers static (the same shape calendar.worldapi.org's soonOf already uses). components/show.hl's own helpers (buildRows, genreRowsOf, …) are static for exactly this reason.
  • Data fix: tools/relink-episode-seasons.hl, a one-off idempotent tool that links every episode whose season was still null in the migrated data (36,194 of them, ticket #2) to its season by matching (show, seasonNumber) — never touches an episode whose season is already set, never runs update() on the live table (hybriel#113; it rebuilds a fresh episodes table and swaps the files over, the same rule tools/migrate.hl follows). Not run against the live data yet — the architect runs it (stop the container first, storage/mpackdb is not shared across 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 unwatched episodes of shows followed in release date desc order"; "check icon is a disc with a check in it" — the SAME icon/class (watches.hl watchClassOf, moved there from components/show.hl so both 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 toggles back, 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: "/schedule has a similar list without check icons for the upcoming episodes of shows followed" — same row layout as /unwatched (show title linking to /shows/:slug, "Ep <number> · <title>", the release date), minus the check icon and minus any click handling: the page is read-only, so it needs no on server face at all. Signed out: no rows, the same "Sign in to see the shows you follow." message as /unwatched.

What it does (step 7)

/my/shows (components/myshows.hl): every show the signed-in user follows, ordered by the follow's at (epoch ms), newest first. Creator: "trackers /my/shows that just ists the shows in desc order i followed them, small poster, title, last episode like s08e35". Read-only, no on server face (like /schedule).

  • Each row: a small poster (2.5rem wide, the same /posters/<name> route + placeholder as the show page; a show with no poster name at all gets /posters/none, i.e. the route's placeholder), the title linking to /shows/<slug>, and the last watched episode as SxxEyy (zero-padded to two digits) = the HIGHEST season/episode number the user has an episode watch for (architect's reading, 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 now builds 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 are function 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 the show page's buildRows look up that map. Now ~0.6 s server render (the rest is fetching every 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 a time — 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 a poster 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,… (≤ 20 seasons with their episodes per request). Bearer TMDB_READ_TOKEN.
  • Matching, never duplicating, never deleting: episode by tmdbId, else (161 migrated episodes have none) 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, release updated; 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 that already has one; a TMDB season with no episodes is not created.
  • Posters: TMDB poster_path, size w342 → storage/mpackdb/posters/<oldId>.<ext> (the folder the existing /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 requests for 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 tick never runs inside a page render. After a show with n requests the next waits n × 260 ms (TMDB ≤ 40 requests / 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 persisted every 10 shows and at the end (~36 ms).
  • The tool (same sync, sequential, paced; prints one line per show + the totals). App stopped or a COPY only — hl:mpackdb is one process per storage (hybriel#21):
  set -a; . ./.env; set +a      # TMDB_READ_TOKEN, never print it
  TRACKER_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 episodes updated, 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 manifest and 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, the apple-touch-icon and theme-color from every head. /favicon.ico is a real icon (16/32/48) for browsers 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 on the screen, #4ec9b0 on rgb(25,30,35); everything inside the maskable safe zone, a circle of radius 204, so one image serves any and maskable); favicon.svg is the same drawing cropped tight on a rounded tile. Rendered on Loreana:
  rsvg-convert -w 192 -h 192 icons/icon.svg -o icons/icon-192.png
  rsvg-convert -w 512 -h 512 icons/icon.svg -o icons/icon-512.png
  rsvg-convert -w 180 -h 180 icons/icon.svg -o icons/apple-touch-icon.png
  for s in 16 32 48; do rsvg-convert -w $s -h $s icons/favicon.svg -o /tmp/fav-$s.png; done
  magick /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 / comes from the cache and every other page gets hl:web's own "Unavailable offline" page (503) — data pages need the network, no offline data. The shell (components/main.hl) shows "You are offline. Your shows and lists need the network." while navigator.onLine is false: hl:web gives a page no connection state 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 write only 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 the title (phone: between the title row and the full-width poster; desktop: under the title, right of the poster) one small 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 checked 2026-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 extra request); 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 show has 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 the sync 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/hybriel tools/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 by season/episode number: a missing episode is ADDED (tmdbId = null, tvmzId, oldId 'tvmaze-episode:<id>'; its season too if missing: oldId 'tvmaze-season:<tvmzId>:<n>'), an existing one gets an EMPTY title / air date filled. 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 flip daily). 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 (other premiere dates, placeholder titles) are skipped.
  • Totals/tool line gain tvmazeEpisodes= tvmazeSeasons= tvmazeFilled= tvmazeSkipped=; tvmazeRequests now also counts 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 that actually 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 gets the 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; a title's year is one more word); each word's first 2 and 3 letters are bucket keys. A query reads ONE bucket (its longest word's first 3 letters, 2 for a 2-letter word) and keeps entries where every query word starts one of the entry's words. Order: whole name, name starts with the text, first word does, rest; then shorter name; then series before movie, newer first. ≤ 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 straight from 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, genres matched 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 we already 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 bibilogrqphy so 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 (TMDB w185 → 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 and crew of the same title merged: "Hero / Director"), poster (our file, else TMDB's w92 thumb like search's web rows, else the placeholder), 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 012 (old 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 migrated person; 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 the shell'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 (empty TMDB 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 the public 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-credit person, 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 stored already are found by TMDB id (no duplicates). A TMDB failure shows "Could not load the filmography: …"; the next visit retries. 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 stays that 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 data 2026-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 that answer (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 one request at a time) — in parts every other request is served between two steps. The job state lives in memory (jobs); a restart begins the title again, stored seasons/episodes are matched by tmdbId. Stored too: overview (summary only if ours is empty), 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 fills the rest on the first visit; in the search at once). Then detailsAt (ms). TMDB 404 → detailsCheck = 'TMDB 404'; TMDB says adult → flag stored, detailsCheck = 'adult', nothing downloaded. TVmaze/poster failures are logged (detailsError), the title still 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, 3 season 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; the last answer (title, summary, poster, genres, cast, links, collapsed, rows) is assigned on the client, no reload. Persisted at the end. 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): at start every incomplete non-adult title (detailsRepairQueue), ONE completeStep per tick (a series: the same title until it is done), then pauseMsAfter(step) (260 ms per TMDB + 550 ms per TVmaze request). Resumes after a restart (the queue = what is still incomplete); passing failures (no answer, 429, 5xx) go to the back (≤ 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 are completed 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,528 episodes, 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 is stored), 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, no characters 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 not list 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's order; ≤ 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 with ids + credits and passes that answer to syncShowWith — one request less than before; the face searchImport stores the credits, 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 no credits); 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 only shows + 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 stops for 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-call scan 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), main cast, then a "Guest stars" label and the guests. The first 20 cast names show, the rest after "Show all (N)" (→ "Show less"); per crew job the first 6, the rest after "Show all crew (N)". The toggles are CLIENT-ONLY (castClass/crewClass member flip, CSS hides .more while collapsed; no face). The character + comma are the link's ::after (data-role) — with a wrapper element the SSR'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), pages meanwhile median 28 ms / p99 0.44 s / max 1.1 s; show page 0.1–0.2 s full load (Picard HTML 166 kB); "Show all" 2–18 ms. Costs: 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 018 (old 062): #26 + #28 merged into main, short ids for new people, lazy guest stars, lean watch clicks)

  • Merge: branch t26 (#26 25a50bc, #28 10bb3f9) merged into main (#27 short ids, #29). Kept both sides everywhere.
  • Short ids for every new record (shortids.hl claimShortId): shows were already covered (search import, filmography fill). Persons are now covered too, in people.hl castPersonId. That is the only place the app creates people: #26 completion, credits job, daily sync + tools/sync-tmdb.hl, search import, and a guest opened for the first time. A tool in tools/ finds data/old-short-ids.json one folder up.
  • Guest stars are no person records (memory): a series' guest (guest = true, see #28) is stored on the title only. The entry is { person: null (or the id when we already have them), tmdbId, actor, character, profile (TMDB profile_path), episodes, guest }. The page links it to /person/tmdb/<tmdbId>?show=<show id>, a function route (project.hl guestRoute). The first open creates the person (people.hl guestPersonId: name + role from that title's entry, a short id, persisted), logs guest opened: <name> (tmdb <id>) → new person <id> from show <id> and answers 302 → /person/<slug>. That page then fills from TMDB as usual. If the person exists, the route answers 302 at once. Without ?show=, the name comes from TMDB's /person/<id>. An unknown id or a non-number answers 404. Guests are not in the search until opened. The main cast (≥ mainMinOf episodes; every movie cast) and the crew still get records.
  • Watch / follow clicks (components/show.hl): a face taking session makes hl:web re-mount the page and re-send every member derived from the session (SESSION SYNC). Before, that sent the whole show record (showRow, with 300 cast + crew entries) and the cast/crew rows. Now:
  • showRow is a small object (pageShowOf: title, summary, poster, short id, genres, links, follow/watch state).
  • Cast + crew derive from the slug (castOfSlug, crewOfSlug). An adult title (followers only) shows none; it never gets credits.
  • watches.hl watchesOfUser is kept per user until that user's next setWatched. It was a scan of all 11.7k watches, 3× per click.
  • The episode/season faces send their own rows only when the page opened/closed a season (collapsedChanged). Otherwise the session sync's rows are the answer, so they are not sent twice.
  • Real data: STATUS.md mission 018 (old 062).

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 changed descriptions 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 via the 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 follows the LIGHT step (syncShowDelta): details + ids + poster + the record, and only the seasons that can have changed — the ones we don'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 only when empty; cast + crew (#28). A renamed title is re-indexed for the search at once (searchTitleRenamed). Empty TMDB values never overwrite. TVmaze: the series request is GET /shows/<id>?embed=episodes now (show + episodes, one request, was /episodes) — its summary, 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 (moveCopiedSummaries, marker storage/mpackdb/summaries-moved.txt): a summary identical to tmdbSummary (the migration's copy) is MOVED to migratedSummary (mission 023 (old 070): kept, shown nowhere — nothing is deleted). Live copy 2026-10-01/02: 9,647 moved, 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, as before) when it is missing (the first run after the deploy), more than 14 days old (TMDB's lists reach 14 days), or a change list fails (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 / a copy); --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, slowest step 2.5 s, pages meanwhile median 45 ms / p99 0.7 s / max 2.2 s. The old full walk on the same copy: 128 shows, 259 + 128 TVmaze requests, 312 s. First day only: +79 seasons / +4,475 episodes (unfollowed series never synced since the migration), 24 titles renamed (TMDB's en-US name, e.g. "Star Wars: Andor" → "Andor", "Kaun Banega Crorepati(कौन बनेगा करोड़पति)" → "Kaun Banega Crorepati"), 79 TMDB texts, 173 taglines. MEMORY: ~10 MB per synced title are never given back (the runtime, see #28) — a delta run +3.5 GB RSS, the old full walk +2.8 GB. Details: STATUS.md ticket #18.

What it does (mission 005 (old 047): checks after client-side navigation, mobile first, show page)

  • Invisible checks fixed by re-vendoring Hybriel master ff51cf46 (see "Vendored Hybriel"): the old build created SVG built in the BROWSER (client-side navigation, expand/collapse, a check click) in the XHTML namespace — nothing drawn. Server-rendered pages were fine.
  • Mobile first (styles.hl): every rule outside the one @media (min-width: 40rem) block is the phone's (390 px); the block only adds the wide layout. The header is one row on a phone (brand, ident selector, Log out; signed out — mission 012 (old 056) — the selector + "Log in", " with ident" only from 40rem, nowrap); /my/unwatched + /my/schedule rows are two lines on a phone (show + date, the episode below), one line from 40rem.
  • Show page (components/show.hl): phone = title (+ Follow) first, then the poster at the full content 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 (since ticket #17: DOWN = closed, season-row.expanded turns it UP). Episode rows are not indented (episode check under the season check). Artists orange (#ce9178, token orange). Unwatched check = the inverted solid icon: outline circle + check mark, since ticket #29 in MUTED grey (accent only under the pointer — in accent a page of them read as "all ticked"); 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 with ident" = the ident login link) and sends nothing. The modal is components/modal.hl, copied verbatim from 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 out plus 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"); every series 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 last page, 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 full reload 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 and → navigate() (see #38 below for which page). Hidden when there is only one page. tracker#38 (mission 033): NOT the clicked button's text — the pagination's own handler has already rebuilt its list into the same buttons — on a REAL click before tilelist's handler runs (microtasks drain between two listeners), on a script's el.click() after it (why browser.mjs's .click() checks never saw it) — so that text was whatever label sits at that index ("2" on page 1 read "1" = no navigation, "Next" on page 3 read "199"). Now: li.current if it already differs from the page, else the button's own (not yet rebuilt) text. Gate pager clicks with page.click (Input domain), never only el.click(). The pager-box element is passed along from the click (e.currentTarget): the handler runs after the dispatch, and the clicked button may be gone ("Next" → the last page). Gate: tests/pager.mjs. 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 only slices 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) into place 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 tracker imported 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 and min(episodes, 8) skeleton rows (skeleton-row: disc, number, title bar in the episode row's shape and height; CSS shimmer @keyframes tracker-shimmer) appear under it; then the face showSeasonEpisodes(seasonId) answers the season'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 whole page 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 a watched episode { all, except } — all = mostly watched, except = the other ids), rendered with the page and re-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 010 (old 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 not counted 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 012 (old 056), components/show.hl adultHiddenFor/visibleShow): a title with adult == true is "Not found" for everyone who does not follow it (signed out too; no member carries its row); a follower sees it, and an Unfollow 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 the slug, 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 the daily 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 012 (old 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, at most 2 more tries per start. Persisted every 25 titles (mpackdb's update is durable anyway — the gate's resume check stops 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 010 (old 054).

What it does (mission 014 (old 058): tickets #22–#25)

  • #22 air dates (components/show.hl): every episode row ends with its air date (episodes.release, YYYY-MM-DD like /my/unwatched; none → empty) — in buildRows (server) and in showSeasonEpisodes → filledRows (an opened season).
  • #23 season check = all episodes watched (seasonAllWatched): derived from the episode watches on every render, not from the season's own watch record (a season without episodes still uses that record). Unchecking one episode turns the season off with the face's answer (showToggleEpisode rebuilds the rows), checking it again turns it on. A season click watches every episode when not all are watched (a partly watched season → all), else unwatches all (setSeasonWatched still writes the season record too — nothing reads it for the check any more).
  • #24 movie watched check (show page, isMovie): the episodes' check icon #moviewatch + "Watched"/"Not watched" under the title (phone: above the poster). Face showToggleMovie(slug, session) → watch { targetType: 'movie', target: <the title's shows.db id> } (new target type; tools/verify.hl knows it). Signed out → the sign-in modal ("You need to sign in to follow movies and mark them watched."). /my/movies: "N movies · M watched" and a solid check per watched row.
  • #25 /genres/<genre> (+ /genres/<genre>/page/<n>, components/genre.hl, composes tilelist.hl): the genre by urlSegment (shows.hl genreBySlug; case-insensitive fallback), its public movies AND series together, newest first, 24 per page. Lists in catalog.hl (index.genres, built in the same start scan; key = a movie's release / a series' newest released episode, else its release — so the date is comparable across both kinds; refreshGenres on every refreshCatalogShow, rollDay re-keys the series). Adult/unknown hidden like /movies. Unknown genre → "Not found". The show page's genre pills link /genres/<x>; the old /genre/<x> answers 301.
  • Real data: TMDB's TV genres differ from its movie genres ("Action & Adventure", "Sci-Fi & Fantasy" vs "Action", "Science Fiction") — /genres/Action is movies only (643), /genres/Action-Adventure series only (266), /genres/Drama both (2,665). Not merged (open question for the creator).

What it does (mission 016 (old 060): short ids, ticket #27)

Creator: "i wanted to give all our movies, shows, series and persons own short ids like a9s9a … those ids show below the poster." shortids.hl (all of it), project.hl (route, backfill clock), the two pages.

  • Field shortId on shows.db (movies + series) and persons.db: 5 characters [a-z0-9]. Unique across both tables through ONE in-memory map (shortids.hl owners, short id → { kind, id }), built at start (one scan of each table, ~0.2 s real data); every id handed out goes through it. Not an mpackdb index: those are per table and cannot span two.
  • Old ids: the old export had id on 702 of 9,453 shows (Show.jsonl, base62 counter 100jh…1012y, all lower case). data/old-short-ids.json (old Mongo _id = our oldId → id; made ONCE by tools/old-short-ids.hl from the export on Loreana; committed, deployed with the code) gives those shows their old id back. All 702 are RESERVED: a new id is never one of them, even where the show is gone. Persons never had one. Real data: 702 of 702 kept (Foundation 100sp, Raised by Wolves 100jh).
  • New ids: 5 random characters (hl:crypto, bytes ≥ 252 skipped so all 36 are equally likely), retried while taken or old. New records get one AT CREATION (claimShortId): search import (search.hl importTitle) and the person fill's minimal titles (people.hl addMinimalTitle) — the only places the app creates shows; persons are created only by tools/migrate.hl. The daily sync creates seasons/episodes only.
  • Backfill (records without one — at first all 25,167): in the background, its own every(0.1) clock in project.hl (shortIdTick → shortIdStep): per tick ONE batch of as many records as fit into 40 ms (fetch, set shortId, update); persisted every 20 batches and at the end. The queue is built at start from what is still missing → resumes after a restart. A record whose id another one already holds (never seen; logged "… gets a new one") is re-queued. Log: short ids: N records, X with a short id, Y without, 702 old ids, … ms, short ids: backfill start …, every 5000 short ids: N assigned, M left, s, short ids done: assigned=… oldKept=… batches=… slowestBatchMs=… slowestPersistMs=… seconds=…, or short ids: nothing to do. Chosen over "all at start": 25k records in one go would block the server ≥ 16 s (a tool on the copy: 1,564 records/s; inside the app a record costs more, ~3 ms — not found out why). Real data (copy 2026-10-01): ~300 s, 1,859 batches, slowest batch 62 ms, slowest persist 31 ms (STATUS.md mission 016 (old 060)).
  • Shown (components/show.hl, components/person.hl, styles.hl shortIdLabel): #shortid under the poster (phone: under the full-width poster) and under the person's photo — no photo: under the name and facts; small (.8rem), muted, monospace, centred under the image, one click selects it. None while the backfill has not reached the record yet.
  • /<shortId> (project.hl shortIdRoute, the LAST route — every named one-segment route wins): 301 → /shows/<slug> or /person/<slug>; upper case is read as lower; unknown / not 5 chars → 404 (plain text). An adult title's page itself then says "Not found" to non-followers as before.
  • Count on a COPY (or app stopped): TRACKER_STORAGE=… TRACKER_OLD_SHORT_IDS=$PWD/data/old-short-ids.json ./bin/hybriel tools/count-short-ids.hl → per kind with/without/malformed, old kept/lost, duplicates across both.

What it does (mission 022 (old 068): typed headings, series / shows split, tickets #20 + #21)

Everything (kinds, the backfill, addresses, the /shows/<slug> function route, colours): [docs/kinds.md](docs/kinds.md). Code: shows.hl (kindOfTitle, titleKind, titlePath, typeWordOf, typeClassOf), catalog.hl (two TV lists), tmdbsync.hl (kindTitle, type stored by the sync / adult backfill), search.hl (import), project.hl (showsRoute, routes, kindTick), components (headings, allseries.hl, myshows.hl for /my/:tv, home), styles.hl (type-*, h1.typed).

What it does (ticket #19: franchises and timelines)

Details: [docs/franchises.md](docs/franchises.md). New tables franchises.db, timelines.db, collectionchecks.db (franchises.hl). /franchises (list), /franchises/<slug> (index: each timeline sortable Timeline | Release — a series by its LAST episode), /timelines/<slug>; on every show/movie page one widget per timeline: franchise above, ‹ Prequel | name | Sequel ›. The creator (TRACKER_EDITORS, az5b2) edits at /franchises/edit and /timelines/<slug>/edit. A background seed turns TMDB movie collections (≥ 2 of our movies) into timelines (one movie per step, ≤ 40 TMDB req / 10 s, resumes).

What it does (mission 025: /my/ pages fast after the jobs, tracker#30; timeline name once; widget position; First Contact)

  • Speed (#30): the /my/ lists and the poster tiles no longer read whole title records (cast + crew) or an episode per watch on every load. shows.hl keeps three slim caches: cardOf(id) (title, year, kind, href, poster name), episodeRowsOf(id) (a title's episodes { id s e title release }) and episodeKeyOf(id) ({ show s e } of a watched episode); watches.hl watchedSetOf(user) is kept until that user's next watch. Filled at boot for every followed title and watched episode (log list caches: 128 followed titles, 11648 watched episodes in ~460 ms), refreshed by catalog.hl refreshCatalogShow (every writer of a title calls it). Used by /my/series, /my/shows, /my/unwatched, /my/movies, /my/schedule and the tiles (/, /movies, /series, /shows, genres). Real copy, process after the jobs: /my/series 1.8 s → 0.06 s, /my/unwatched 4.1 → 0.18 s (STATUS).
  • Timeline page: its name only in the h1 (the list head shows count + sort toggle).
  • Franchise widget: inside the show header — phone: right under the poster, before genres/plot/cast; ≥ 40rem: right column under the title/links.
  • Star Trek: First Contact: its tmdbId 199 is RIGHT (it is /movie/199). The old #9 sync asked /tv/199 and stored that TV title's overview, status Ended and season/episode counts. The details repair now also takes a movie with season/episode counts (details.hl hasTvLeftovers) and asks /movie/<id> again, then drops the counts. Real data: only that one record; 0 records share a TMDB id with a different title (68 same-type ids are held by 2–6 records of the SAME title — duplicate imports, see STATUS open points; tools/count-tmdb-ids.hl).

What it does (mission 026: duplicate titles merged, tracker#31; deploy waits for the boot)

  • Where the duplicates came from: the OLD tracker. On the live copy of 2026-10-03, 68 type+tmdbId pairs were held by 157 records (Archive 81 ×6, Peripheral ×4, Ally McBeal, The Flight Attendant, 60 adult movies ×2) — every one a migrated row (Mongo oldId, made 2022–2023 in runs of consecutive ids, several sharing one slug). This app's two creating paths already look the title up first (search import search.hl importTitle, filmography fill people.hl fillStep → titleIdByTmdb), and their records carry oldId = tmdb-<tv|movie>-<id>, which the !oldId UNIQUE index refuses twice. No extra index: an mpackdb unique index refuses a second null (tombstones and titles without a TMDB id) and opening a table with a new index is untested (#21). Hardened: the import looks the id up again right before its put; a merged tombstone answers its keeper everywhere (shows.hl liveShowOf); search.hl searchTitleMerged points the TMDB id at the keeper.
  • Seasons: 449 migrated seasons name a title that does not list them (the old tracker showed seasons by query). The daily sync goes by the list, so where TMDB has that number it made a second season — Reacher: the creator's S3 watches sat on the unlisted migrated S3, the page showed the sync's S3 unwatched.
  • The repair (merge.hl, in the app, own 0.1 s clock, one item per tick, no TMDB; the TMDB jobs wait until it is done): per duplicate group the KEEPER = the record with follows/watches, else the one with its old short id, else the oldest; follows, watches, seasons (moved, or merged episode by episode into the keeper's season of that number), cast + crew, persons' credits, timelines and empty fields go to it; the keeper takes the plain slug of its title when a duplicate held it (Archive 81: Archive-82 → Archive-81, the former in oldSlugs). The others become TOMBSTONES — never deleted: mergedInto, mergedAt, mergedTmdbId (tmdbId null), mergedSeasons (seasons []). A STRAY season with a listed twin is merged into it; one without is linked into the title's list only when someone watched it (linkedSeasons) — linking all 393 with episodes put 1,091 never-watched old talk-show episodes on /my/unwatched. A user who has both a watch/follow on the keeper and on a duplicate keeps the keeper's; the other row is parked (target/show null, mergedInto) — no row is deleted. Resumes after a restart; a later start logs merge: nothing to do.
  • Redirects: a tombstone's slug and a keeper's former slug answer 301 to the keeper — /series/…, /movies/… (function routes made at boot from shows.hl movedSlugs, log moved slugs: N; a title merged while the process runs shows the keeper's page under the old slug until the next start) and /shows/… (showsRoute). A tombstone's short id /<sid> → 301 at once.
  • Real copy (STATUS mission 026): 68 groups → 0, 89 tombstones, 29 seasons merged (Reacher S3, Vikings S0 ×3, Raised by Wolves …), 2 watched strays linked (Supergirl S0, The Peripheral S0), 9 watches moved, 44 credits re-pointed; ~10 s, slowest step 187 ms. The creator's follows (130) and watches (12,139 rows, 12,136 live) the same before and after; Reacher S3 checked, /my/series S03E08, /my/unwatched 1,273 → 1,265 rows (Reacher S3 gone); Archive 81: one search hit, one page, all 8 watched.
  • deploy.sh: the URL check asks every 2 s for up to 90 s (DEPLOY_URL_WAIT), printing only changes (after 14 s: 502 … after 30 s: 200) — the boot (search index over ~100k people, list caches, slug map) answered 502 twice while live was fine.

What it does (missions 029 + 030: reduced titles, Follow under the poster, status pill — tracker#33, #34, #35)

  • Complete (lib/shows.hl isIncomplete) = TMDB's details went through this app once: detailsAt (details.hl completion) or tmdbSync (sync.hl's last step) — or detailsCheck (404 / adult). Everything else with a TMDB id is incomplete: #26's filmography titles AND the old tracker's never-synced migrated rows (#26 let them pass). missingParts(s) = what a page lacks (poster, summary, genres, cast, ids, release, seasons); the repair job does those first. tools/count-incomplete.hl [--list] (copy / stopped app) prints reduced titles: N. A complete title can still lack a part — TMDB has none (The Remaining: no poster, no IMDb id); the page then shows /posters/none in the poster's place.
  • Follow (components/show.hl): in show-poster, right under the poster image, width: 100% of the poster column.
  • Status pill (shows.hl statusOf, <status-pill class="status-pill status-<tone>"> after the h1): tvmazeStatus (Running, Ended, To Be Determined, In Development) else TMDB status ("Returning Series" → "Returning"); TVmaze Ended + TMDB Canceled → "Canceled". Inverted (filled, dark text, no border): running green, pending yellow, canceled red, ended/other muted. Series and shows only. tvmazeStatus comes from the sync's TVmaze merge (GET /shows/<id>?embed=episodes), from the daily delta for an unfollowed series TVmaze's change list names (dailysync.hl syncRunStep → sync.hl syncTvmazeStatus, GET /shows/<id>) and from the status backfill after the details repair (jobs.hl statusTick, backfill.hl tvmazeStatusTitle; same switch TRACKER_REPAIR; 550 ms per request; 404 → tvmazeStatusCheck). Log: tvmaze status: start, N series …, tvmaze status done: …. Label of TVmaze "Running" (mission 033, creator: "Running" reads stale): "Airing" while an episode of the two newest numbered seasons has a release within today ±7 days (UTC, both ends; specials/season 0 don't count — shows.hl isAiring), else "Returning". Only the label; tvmazeStatus stays "Running", same green. Other words unchanged. Gate: browser.mjs #35 (tests/seed-status.hl: Running −10/+10 days + a special in 2 days → Returning, Airing +3 → Airing, Aired −7 → Airing).
  • Real copy: repair 5,834 titles in 2 h 6 min (+1,933 seasons, +65,433 episodes), reduced 5,834 → 0; status backfill 1,558 series in 18 min (STATUS.md missions 029/030).

What it does (mission 031: the WorldAPI theme and the header — tracker#32)

  • Theme: theme/ = layouts.worldapi.org's theme/ copied verbatim (cp -r /media/STORAGE/projects/layouts.worldapi.org/theme ./theme; shared/tokens.hl must stay byte-equal to that project's — cmp). Never edit theme/ here: change it there, copy again. components/styles.hl starts with inherit '../theme/theme.hl', sets colorAccent (green-dark) and the type colours (colorType1 Movie yellow, 2 Series green, 3 Show purple, 4 Genre blue, 5 Person orange, 6 Franchise light blue) and keeps only the tracker's own rules. A selector the theme writes may not be written again (hl:web refuses it). The theme's buttons are QUIET: a main action carries class="primary" (Following, Fetch from web, Create franchise/timeline, Sign in with ident); inverted (Follow, Add) = the accent on a surface, no border. The only border left is the season foldable's (// functional: comment). Docs: layouts.worldapi.org docs/using-the-theme.md, docs/layouts/*.md, design rules docs/design-rules.md.
  • Gate helper: node theme/check-theme.mjs . — hard-coded colours and decorative borders in our .hl/.css; must print 0 problems (was 21). .check-theme-ignore skips the copied components components/pagination.hl, components/modal.hl (the theme removes their frames). Fourth entry of deploy.sh's GATES.
  • Header (components/main.hl, the theme's AppHeader, MainMenu, UserMenu, Sidebar, ContentFirst): from 60rem brand · search field (centred between brand and menu) · Series | Shows | Movies | Genres | People · user icon, contents flush with the content column (72rem). Signed in, the icon's dropdown: Unwatched, Schedule, My series, My shows, My movies, Settings, Logout (#logout, a button: the face trackerLogOut). Signed out: <ident-menu> in the icon's place — on desktop the selector itself; on the phone the ident icon (svg: a small i beside a big D lying on the floor, its lower face = the thickness) opens a dropdown with the selector and "or log in at ident" (#loginbutton, the login button flow with ?next=). Phone: ☰ (the sidebar overlay #sidebar with the main menu; a link closes it) · brand · loupe (the search field under the bar, focused) · icon far right.
  • Search field: GET /find?q=<text> (project.hl findRoute) → 302 /search/<text> ('/', '?', '#' → spaces), empty → /search (a page cannot read a query string).
  • Marked menu entry: hl:web tells a shell no path, so MainMenu gets no current; styles.hl marks (accent) the entry of the type the page's typed heading names (body:has(main h1.typed > type-word.type-series) → Series …). No aria-current on it.
  • Shell structure: Sidebar then main { ContentFirst { slot } } — NOT the theme doc's ContentFirst { Sidebar … main { slot } }: hl:web renders a component's slot children on one line, and a page slot whose marker does not start its own line is rendered once per line of the page (WebFramework.hl document(), slotIndent = the text before the marker) — the page came out ~50× with hydration errors. Here main wraps the shell, the page is the shell's slot.
  • /genres (components/genres.hl, lib/catalog.hl genreList): every genre with public titles, A–Z, with its count → /genres/<g>.
  • /people, /settings: replaced by mission 032 (tracker#37), below.

What it does (mission 032: /people, people in the search, /settings — tracker#37)

Creator: "/people just lists persons by last one updated with poster name and pagination. the search should find people too. /settings is just an empty page with heading Settings for now."

  • /people, /people/page/<n> (components/people.hl, lib/people.hl peoplePage): every person (no adult filter, as before), the one updated last first: key = newest of updatedAt (new: stamped by the person page's TMDB fill), filmographyAt, added; the migrated people without any come last. 24 tiles per page through components/tilelist.hl (like /movies; tileClass = "tile person-tile": the name in the person colour, no year), "105018 people, last updated first · page 1 of 4376". The old /people/<letter> routes are gone (404).
  • A cached view (like mission 025's list caches): warmPeopleView() at boot (project.hl) = one pass over the people + one sort (util.hl sortDesc now starts from the runs already in order: same result, 105k nearly sorted people 0.15 s instead of 1.6 s) + the cast photos (below); log line people view: 105018 people, 51922 cast photos in 3753 ms. A page slices 24 ids and fetches those people (live copy: ~14 ms server time on any page, 67–85 ms full load). A person created (castPersonId) or updated (the fill) is personTouched → first on /people from the next read on (no restart).
  • The photo (people.hl personPhotoOf, the tiles and the search rows): our file (/profiles/<oldId>.<ext>), else TMDB's tmdbProfile (w185 from TMDB, like the filmography's TMDB posters), else the profile path in a title's cast/crew entry (an in-memory map built at boot — NOT written to the people: 51,907 updates took 511 s at a start on the live copy), else /profiles/none = the new "no photo" placeholder (assets/photo-placeholder.svg). From now on new people keep TMDB's profile (castPersonId stores tmdbProfile; crew entries + created_by carry profile too). Live copy: the newest 72 people (pages 1–3) are all CREW people of the credits job's last titles — crew entries had no profile before this mission, so they show "no photo" until their page is opened (the fill downloads it) or their title's credits are stored again; cast people with a TMDB photo show it (e.g. page 20: 24 of 24).
  • Search (components/search.hl): people rows now have the photo before the name (bold, person colour) → /person/<slug>; the DB search and "Fetch from web" are unchanged. "Jared Harris" → Jared Harris with his photo.
  • /settings (components/settings.hl): only <h1>Settings</h1> (plain: the typed headings are "<type> | <name>").

What it does (mission 035: two corner radii)

Creator: "no different border raiduses … usually i have around 5px/10px". theme/ re-vendored verbatim from layouts.worldapi.org 0222f67 (radiusSmall 5px, radiusLarge 10px; --layout-radius gone; its check-theme flags any other radius). components/styles.hl: small = pills (status, genre, genre links), the external-link chips, fields (search, editor), small thumbnails (search / my-shows / filmography rows), skeleton lines, the sort toggle; large = posters, photos, tiles, home tiles, the franchise widget, skeleton poster/cast, the ident popover; 50% stays for round things (check icons). Real copy: computed radii of every element on 8 pages at 390/1280 ∈ {0, 5px, 10px, 50%} (tests/realdata-m035-radii.mjs).

What it does (mission 032: backdrop as poster, release + runtime, the art and photo backfills — tracker#33, #37)

The Remaining (TMDB 1775752) had everything TMDB has except a poster: TMDB has none, only a backdrop (and runtime 7).

  • Poster fallback (tmdb.hl syncPoster(show, poster_path, backdrop_path), every caller: sync, adult backfill, art backfill): no poster → the BACKDROP (w780) as the title's poster file (posterFromBackdrop = true); every poster box shows it centre-cropped 2:3 (CSS aspect-ratio 2/3 + object-fit cover, already on all of them). A later real poster replaces it.
  • Runtime (runtime, minutes; TMDB's 0 = unknown, not stored): sync.hl syncMovie, details.hl withDetailsFields. A movie page shows #moviefacts "17 September 2026 · 7 min" (components/show.hl movieFactsOf, "2 h 15 min" from an hour).
  • Art backfill (backfill.hl artTitle, jobs.hl artTick): every PUBLIC title without a poster file + public movie without runtime, one GET /movie|tv/<id> each (≤ 40/10 s), artAt (asked) / artCheck (404) → never again. Live copy 2026-10-04: art backfill done: 4977/4977 titles, posters=115 backdrops=15 runtimes=4609 failed=0 requests=5107 … seconds=2527 (42 min).
  • Photo backfill (people.hl photoStep, jobs.hl photoTick, after the art): every person without a photo source (no file, no tmdbProfile, no cast/crew profile), in /people's order, one GET /person/<tmdbId> each → tmdbProfile, else photoCheck. Live copy: 53,106 people, ~0.5 s each ≈ 7.5 h; about a third get a photo (crew mostly has none at TMDB); /people page 1 after ~5 min: 8 of 24 with photo (before 0). Pauses above TRACKER_PHOTOS_MAX_MB (16000) RSS like the credits job.
  • Both are the LAST start jobs (after the franchise seed and everything it waits for), on their own 0.1 s clock; logs art backfill: start/…/done, photo backfill: start/…/done; TRACKER_ART=0, TRACKER_PHOTOS=0 switch them off (every gate and tests/realdata-*.sh start them off; the browser gate's own section turns them on).

What it does (tracker#39: double episodes, mission 032)

Creator: "some series have double episodes like the flash: 100lq" (+ Discovery). Cause: TMDB re-creates episodes now and then (Scrubs S1E1 326465 → 6992247). The migrated episode kept the old id; sync.hl matched by tmdbId and by S/E number ONLY for an episode without any tmdbId → a second episode of the same number was added (daily sync, details repair). Live copy 2026-10-04: 850 extra episodes in 112 series (Scrubs 182, Tonight Show 58 …; 166 in Specials). The Flash's "74" is its Specials season — TMDB really lists ~72 specials; its doubles were 2 there + 9 in season 9.

  • Sync (sync.hl syncShowPart): a stored episode whose tmdbId TMDB no longer lists in that season is adopted by its number; the old id → migratedTmdbId.
  • Merge (merge.hl step 3, the existing merge job at start, TRACKER_MERGE): per listed season, each number held twice → keeper = most watches, else the synced one (tmdb-episode: oldId), else the first; watches moved (a user with both: parked, mergedInto, like #31); a migrated keeper takes the synced one's tmdbId + TMDB texts (its own in migratedTmdbId / migratedTitle / migratedSummary); the others = tombstones (mergedInto, mergedTmdbId, tmdbId null), out of the season's episodes into mergedEpisodes. Log: merge: double episodes of "<title>" season N <id>: k merged, m left; done line … doubleSeasons=186 doubleEpisodes=850. Live copy: 64 s in the background, slowest step 1.4 s; the creator's watch rows 12,525 unchanged (41 parked repeats), /my/unwatched 993 → 905 rows.
  • Check: TRACKER_STORAGE=<copy> ./bin/hybriel tools/count-duplicate-episodes.hl [--list] (app stopped / a copy) → duplicate episodes: extra=0 …. Left: same tmdbId twice in a series: 68 (old-tracker rows with two numbers, not doubles).

What it does (mission 034: the curated franchises — tracker#40)

Creator: "you never build timlines and franchises". data/franchises.json (DATA, not code — correct it freely) lists 17 franchises with 31 timelines by TMDB id (["tv"|"movie", id, "title (year)"], the title only for people), each timeline "order": "in-universe" (the story's chronology) or "release", plus per franchise the TMDB collections (the collection seed's timelines) that join it. lib/franchiseseed.hl + the job in lib/jobs.hl (franchiseSeedTick, the LAST start job): missing titles are imported one per step via details.hl importWithCredits (= the search's "Add": details, seasons, episodes, poster, ids, TVmaze, cast + crew), paced like the daily sync; then one build step. What the seed made is recorded in franchiseseed.db; a seeded timeline the creator changed (order or franchise) is never written again; a franchise/timeline the creator made with the same name is adopted/left alone; a collection timeline with a franchise keeps it; TMDB 404s are remembered. The timeline heads say "23 titles · in-universe order" (orderKind). Series pages show the widget like movies (they always could; now series are in timelines). A TMDB collection timeline wholly inside a curated one is hidden (superseded, kept for the editor) — no second widget. The main menu has "Franchises" (→ /franchises, after People; phone: the sidebar), marked on franchise and timeline pages. Details: [docs/franchises.md](docs/franchises.md) "The curated seed".

Test

SIX gates, all must pass (deploy.sh runs them in a row on the default ports 8700–8710):

mkdir -p /tmp/shots   # TRACKER_GATE_SHOTS must exist before browser.mjs runs
node tests/browser.mjs      # the main gate (below) — mission 032: 369 passed, 0 failed (029: +5 #33; 030: +7 #34/#35; 031: the #32 header states, /genres /people /settings; 032: #37)
node theme/check-theme.mjs . # mission 031: the theme gate helper — "check-theme: N files, 0 problems"
node tests/kinds.mjs        # #20/#21: kinds, the kind backfill, /series /shows /movies, typed headings — 32 passed, 0 failed (collection seed off since mission 024 (old 071))
node tests/franchiseseed.mjs   # #40 (mission 034): curated seed — imports, adopt/keep the creator's, collection attached, resume, idempotent, editor wins, series widget — 24 passed
node tests/franchises.mjs   # #19: franchises/timelines, seed, widget, editor; mission 023 (old 070): typed headings + /movies|/series links; mission 025: widget position, timeline name once — 52 passed
node tests/pager.mjs        # #38 (mission 033): real clicks 2,3,Next,Previous,11,10,…,1,Back,Back,Forward,Next,Back on /movies /series /shows /genres/<g> /people (11 pages each, tests/seed-pager.hl) at 390 + 1280 (real clicks) and 1280 (script el.click()): address, tiles, current page, same document — 229 passed, 0 failed (main before the fix, real clicks only: 113 passed, 40 failed). Ports: TRACKER_GATE_PORT + TRACKER_GATE_CHROME only
# a worker on other ports: TRACKER_GATE_SHOTS=/tmp/x TRACKER_GATE_PORT=8750 TRACKER_GATE_IDENT_PORT=8751 TRACKER_GATE_CHROME=8752-8756 TRACKER_GATE_TMDB_PORT=8757 node tests/<gate>.mjs

Mission 031 (tracker#32) in browser.mjs (the older header checks below — "Log in with ident" beside the selector, "Log out" button, brand weight 100, the 1px Follow frame — were replaced): signed out at 320/390 ☰ · brand · loupe · ident icon in one row ≤ 56 px, no field/menu/selector until tapped; at 1280 brand · search (centred between brand and menu) · the five menu links · the selector, brand left = content column left, selector right = column right; phone: the ident icon opens the dropdown (selector + "or log in at ident"), Esc closes it; ☰ opens the overlay from the left with the five links, a tap on Movies navigates client-side, closes it, marks Movies; the loupe opens the field focused, Enter → /find → /search/gate with results; /find redirects; signed in at 390/1280 the user icon far right, its dropdown Unwatched|…|Settings|Logout inside the screen / flush with the column, Series marked on a series page; Settings → /settings (client-side, dropdown closed); /genres A–Z with counts (Genres marked); /people letters → a letter's tiles A–Z (People marked); Logout via the dropdown. Shots: header-*.png in TRACKER_GATE_SHOTS. Mission 032 art/photos in browser.mjs (tests/seed-art.hl; fake TMDB: backdrop_path, runtime, w780): the art backfill asks each title once, Backdrop Gate Movie/Series get the backdrop (w780) as poster; the movie page shows /posters/tmdb-movie-1401.png object-fit cover, 2:3, "17 September 2026 · 2 h 15 min"; the photo backfill (after the art) asks 3401/3402/3403 once: TMDB w185 photo / placeholder / placeholder; the next start: both "nothing to do", no request. tracker#39 in browser.mjs (tests/seed-dupes.hl Double Episode Series, tests/paths-m039.hl): count-duplicate-episodes 2 before → 0 after the merge (8 tombstones incl. #31's 6); merge start/done/season lines; the page Season 1: 2 episodes, alice's /my/unwatched S01E02 + S02E01; the sync of tv 1305 adopts S2E1 by number (9201 → 9301, migratedTmdbId), adds only S1E3. With the OLD sync.hl that check fails (S2E1 twice) — verified. Mission 032 (tracker#37) in browser.mjs: the first start logs people view: 3 people, 0 cast photos; /settings = only <h1>Settings</h1>; /people via the menu = person tiles (photo, name in the person colour → /person/<slug>), "N people, last updated first · page 1 of M", no letters, People marked; /profiles/none says "no photo", /posters/none "no poster"; after the #28 sync tool + restart the search shows TMDB w185 photos for new cast (Late Season Star), created_by (Show Creator) and crew (Episode Director) people, the placeholder for one without, and Test Actor's from his cast entry (that start logs 4 cast photos); after Test Actor's fill he is FIRST on /people (no restart) with /profiles/p1.png, 24 per page, page 2 continues (none twice), the search shows the same photo; after #26 creates New Cast Member it is first (TMDB photo from the cast), Test Actor second. Real copy for #37: as below with REAL_PATHS=/people,/people/page/2,/people/page/3,/people/page/20,/search/Jared%20Harris,/settings; server times curl -s -o /dev/null -w '%{time_total}' http://127.0.0.1:8731/people/page/3. Real copy (not a gate): tests/realdata-m028.sh start <tree without .env> <copy> 8731, then REAL_PORT=8731 REAL_CHROME=8729 REAL_SID_FILE=<copy>/sid REAL_OUT=/tmp/x node tests/realdata-m031.mjs — screenshots <w>-<in|out>-<page>.png + header states, prints header height / sideways scroll / console problems per shot. LOOK at them.

node 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 005 (old 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 010 (old 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 012 (old 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.
                            # mission 014 (old 058): #22 air dates (server + opened season), #23 season
                            # check off/on live + after reload + partly watched → all, #24 the
                            # movie check signed out (modal) / in (click, reload, uncheck) +
                            # /my/movies count, #25 /genres/Action (2 pages, series + movies,
                            # adult hidden, pill → /genres/Drama, /genre/ 301, imports at once).
                            # Fixture: genre Action on the Pager titles + adult "Hidden Action Film".
                            # mission 016 (old 060) (short ids): the gate's own old-id file (sh1 → 100jh,
                            # 'gone-old' → 100ji reserved); the first start's background
                            # backfill (oldKept=1); a search import has its id at once;
                            # #shortid under the poster/photo (person without photo: under
                            # the facts), monospace, muted, smaller, 390 + 1280 (shots
                            # shortid-{show,movie,person,person-nophoto}-{390,1280}); the
                            # fill's new titles have one (the run's backfill was done
                            # before); /<id> → 301 (show, movie, person, upper case),
                            # unknown / reserved / 3 chars → 404, the browser lands on the
                            # page; tools/count-short-ids.hl (app stopped): all have one,
                            # 0 duplicates; RESUME: tests/seed-shortids.hl adds 60 persons
                            # without + 2 sharing aaaa1, a start with a 1 ms budget is
                            # stopped part way, the next does only the rest, one of the two
                            # keeps aaaa1, the other gets a new one, a 3rd start: 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 / +25 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.
                            # mission 018 (old 062) (merge of #26/#28 + 3 fixes): count-short-ids after
                            # the sync tool and after the credits job (app stopped): every
                            # person has a short id, 0 duplicates; the #26 new cast member,
                            # the import's Imported Lead / Import Creator have one at once;
                            # the next start: "short ids: nothing to do". Guests: Guest Star
                            # One/Two link /person/tmdb/<id>?show=<id>, no person page before;
                            # a click creates Guest Star One (short id, filled from TMDB:
                            # Gate Test Show / Stranger), shots guest-person-{390,1280}; the
                            # link again → 302, no 2nd person; without ?show= the name from
                            # TMDB /person/3141; /person/tmdb/999999 + /abc → 404. A watch
                            # click's ack + session sync: rows, NOT castRows/crewRows/names,
                            # < 20 kB; cast + crew still there after two clicks; after a
                            # client-side navigation a watch click answers only ok (rows by
                            # the session sync), flips + back, every row once.
                            # 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.
                            # 323 checks (#13 + #14 + #15 + 1 merge check + 17 mission 010 (old 054) + #16: 19 + 5 mission 012 (old 056) + 6 #17 + 21 mission 016 (old 060) + 17 #26 + 18 #28 + 7 mission 019 (old 063) + 10 mission 018 (old 062) + 12 #18). Own servers :8700 (this app) / :8701 (ident copy)
                            # / :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,genre-action,genre-action-page-2,movie}-{390,1280}.png,
                            # movie-watched-{390,1280}.png, movie-signed-out-modal-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 them
node tests/kinds.mjs        # mission 022 (old 068) (#20, #21): its own gate — own storage .scratch/kinds-store, own ident, fake TMDB
                            # with TV types (tests/seed-show.hl + tests/seed-kinds.hl): kinds from stored data, the daily
                            # sync storing the type, the kind backfill (stop + resume, pacing, 404, nothing to do), the
                            # two lists, 301s, every heading's words + computed colours, the function-rendered show page
                            # (season, Follow), home rows/tiles, /my/series + /my/shows, search labels + a talk-show
                            # import → /shows/<slug>, person labels, 390/1280 no sideways scroll + the long name wrap.
                            # TRACKER_GATE_SHOTS=/tmp/w068/shots TRACKER_GATE_PORT=8761 TRACKER_GATE_IDENT_PORT=8762 TRACKER_GATE_CHROME=8763-8765 TRACKER_GATE_TMDB_PORT=8766 node tests/kinds.mjs
                            # → 32 passed. Shots {series-page,show-page,movie-page,genre,person,series-list,shows-list,
                            # movies-list,home,my-series,my-shows,search}-{390,1280}.png — LOOK at them.
                            # The shots dir must exist for tests/browser.mjs (mkdir -p first).
ps -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/ via nginx (TLS ends there; no baseUrl/tls in the app, like ident/notes).

  • First deploy: done by the architect (folder, nginx vhost with WebSocket Upgrade headers, 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) to Loreana's /media/SLOW1TB2/deploy-backups/<app>/ (newest 5 kept; an empty/failed backup stops the 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 of mission 018 (old 062) (#26 + #28 first time live): after the start the details repair runs ~14 min (557 titles), then the credits job ~50 min (5,867 titles). Watch docker stats and the log (details repair …, credits job …). Real-data copy: people 15k → 102k. RSS after boot 4.3 → ~8 GB, swinging 1.2–8.8 GB during the jobs. One step was ~5 s, so pages waited that long once. TRACKER_CREDITS_MAX_MB (16000) pauses the job.
  • Deploy of mission 023 (old 070) (#18 + #20/#21 + #19 + Hybriel 8efba065 first time live; measured on a copy of live storage/ of 2026-10-02 with real TMDB): start ~1 min (search index; ONE TIME the summaries move, 9,647 → migratedSummary, ~16 s); adult backfill / repair / credits have nothing to do; kind backfill ~16–21 min (1,789 TV titles, 1,522 requests), then the collection seed ~32–37 min (4,774 movies, 4,957 requests, 110 timelines) — ~50 min in all. No sync-state.txt yet → the first 04:00 UTC run is the full walk; from the next day the delta run (~6–10 min). Log: kind backfill done, collections done. #40 (mission 034): after every other start job the curated franchise seed (real copy 2026-10-04: 196 imports, 636 TMDB + 93 TVmaze requests, ~7 min, RSS ≤ 2.7 GB; log franchise seed done: …). The titles it imports are new movies → the collection seed checks them on the NEXT start (e.g. adds TMDB's "Star Wars Collection" as a loose timeline). Memory: RSS after boot 2–4 GB; during the jobs up to 11.8 GB (swinging back to 1–3 GB). After the jobs the process keeps ~25 MB per page load (3 → 19 GB in 600 loads) — restart the container once after collections done (docker restart tracker.worldapi.org); a fresh process keeps ~3.5 MB per load, after one daily delta run ~8 MB → a daily restart after the 04:00 run is advisable (not built). The old binary kept ~21 MB per load from the start. /my/unwatched takes ~5 s (old binary 1.2 s): Hybriel's GC by bytes (HL_GC_BYTES, README "Config"). Watch docker stats. STATUS.md mission 023 (old 070). Re-measured in mission 024 (old 071) (live copy of 2026-10-02 10:50): the old binary's jobs took 53 min (peak 13.6 GB), 8590df63 after its jobs swings 13–31 GB under page loads — the live process today; whichever binary goes live, restart after collections done.
  • Deploy of mission 026 (tracker#31): the first start merges the duplicates in the background (~10 s on the real copy, log merge: start, 68 duplicate groups (157 records), 15 titles with stray seasons … merge done: groups=68 tombstones=89 …); the other jobs wait for it. The moved slugs answer 301 from the NEXT start (moved slugs: 28) — restart once after merge done (docker restart tracker.worldapi.org). Every later start: merge: nothing to do (~1.2 s scan on the first tick).
  • ./deploy.sh --dry-run = the gate + rsync -n + the commands it would run (no restart, no URL check). --target DIR|HOST:DIR and --url URL point it elsewhere (tested against a local directory, see STATUS.md).

Data

storage/mpackdb/: users.db (identity → this app's own user id, users.hl) plus, since step 2, the old tracker's data — genres.db (27), persons.db (15157; #16 adds biography, tmdbProfile, adult, filmographyAt and TMDB credits in shows), shows.db (9453), seasons.db (6430), episodes.db (231584), follows.db (128), watches.db (11692), all with fresh mpackdb ids and every reference re-pointed (tools/migrate.hl); the one old user is this app'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 on shows. #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-moved.txt (the one-time move ran: counts). Mission 023 (old 070) (nothing migrated is destroyed): shows.migratedSummary (the copied summary), migratedTitle / migratedGenres / migratedHomepage / migratedTagline (a MIGRATED record's value before TMDB's first replaced it; set once), seasons/episodes migratedTitle / migratedSummary (same rule) — kept, shown nowhere. Mission 010 (old 054): shows.adult (true/false/null = unknown → hidden from lists + search) and shows.adultCheck (why the backfill gave up on a title, e.g. TMDB 404). Mission 016 (old 060): shows.shortId and persons.shortId (5 × [a-z0-9], unique across both; data/old-short-ids.json holds the old tracker's 702). #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 added from a cast: oldId tmdb-person-<id>, added (ms). #28: shows.cast [{ person, tmdbId, actor, character, profile, episodes, guest }] — mission 018 (old 062): a guest's person is null until someone opens them (/person/tmdb/<id>); shows.crew, creditsAt, creditsCheck. Known in the migrated data (not caused by the sync): 12 duplicate episode tmdbIds and 1 episode whose show/season disagrees among the followed shows. Counts and the referential-integrity proof: STATUS.md "ticket #2". Mission 026 (tracker#31, merge.hl): a merged duplicate keeps everything and gets mergedInto (the keeper's id), mergedAt, mergedTmdbId (its tmdbId is null), mergedSeasons (its seasons []); a keeper may have oldSlugs (former slugs, 301) and linkedSeasons (stray seasons the repair linked); seasons/episodes merged into a twin: mergedInto (+ season mergedEpisodes), moved ones movedFrom; follows mergedShow, watches mergedTarget (where they were), a parked repeat mergedInto (+ show/target null). Real data 2026-10-01: 7,814 movies + 1,639 series (shows.type), 232,384 episodes (935 series have a released one).

Code order

Rules (creator, 2026-10-03, /CONTAINERS/projects/antcolony/docs/code-order.md on Byrodin; done in mission 028):

  • let only for a variable that is reassigned later; everything else is a plain declaration (x = …). A declaration inside a LOOP BODY keeps let (a plain one is "Cannot reassign immutable variable" on the second pass). Check / fix: python3 tools/let-audit.py --list project.hl lib/*.hl components/*.hl tools/*.hl tests/*.hl (--fix rewrites; it keeps a let whose name is also a member, import, outer or free name — a plain write would rebind that one).
  • project.hl is the map: config, routes, wiring and an index (feature → file) at its top. No process functions in it; its function routes check the input, call the topic, answer.
  • No .hl in the root except project.hl: components/ the pages, lib/ the logic (one file per topic, its central logic; noise and helpers in lib/<topic>-helpers.hl, shared helpers once in lib/util.hl), tools/ command-line tools, tests/ gates and real-copy checks.
  • Statics cannot be reassigned: a topic's state lives in hybrids written key by key (run.pos = …) or plain lists filled in place; state rewritten as a whole every step (the background jobs) lives in the one class instance lib/jobs.hl.
  • A cleanup never changes behaviour: the 3 gates and the real-copy page diff (tests/realdata-m028.sh + .mjs, STATUS mission 028 "How to repeat") before and after.

Files

Code

File
CONCEPT.mdthe creator's concept — do not edit
project.hlTHE MAP (mission 028): config, the installable app (appIcons, appTouchIcon, appFavicon, theme colours from the tokens, offline = [ Home ], #10), every route, the thin function routes (/login/callback, /posters/:name, /profiles/:name, /unwatched + /schedule + /genre/:genre → 301, /shows/:slug = showsRoute (a show rendered, a series/movie → 301), /person/tmdb/:tmdb = guestRoute, the moved slugs' 301s, /:sid → 301 last), the push audiences, the session cookie, the boot calls (search index, short-id map, list caches, summaries move, franchises, moved slugs), the server, jobs = new Jobs()
lib/util.hlthe shared helpers, once: envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc
lib/users.hlWHO IS SIGNED IN: the usersTable, the ident exchange (exchangeCode, userOfCode — both logins), userOfSession/userIdOfSession; the /login/callback logic (loginCallbackOf, safePath); copied from calendar.worldapi.org
lib/shows.hlthe titles: the tables shows/seasons/episodes/genres/persons, read access, slugs (showBySlug map, merged slugs movedSlugs/movedPathOf, showsMovedPath for /shows/<slug>), kinds (titleKind, kindOfTitle, titlePath), isPublicTitle (adult), isIncomplete (#26, #33) + missingParts, statusOf (#35 the pill), summaryOf (#18), list labels (episodeCode, titleWithYear, posterUrlOf), externalLinksOf (#12), the slim list caches (cardOf, episodeRowsOf, episodeKeyOf, #30)
lib/follows.hlper-user follows: followsOfUser, followedShowIds, allFollowedShowIds (the sync's list), isFollowing, setFollowed, toggleFollowed (the Follow button)
lib/watches.hlper-user watches (episode / season / movie): watchesOfUser + watchedSetOf (kept per user until their next write), watchClassOf, setWatched, setSeasonWatched, the checks clicked (toggleWatched, seasonAllWatched, toggleSeasonWatched)
lib/catalog.hlthe public lists (#13): built once at start, refreshed per changed title (refreshCatalogShow) and per new day; only adult == false; movies / series / shows / per genre (#21, #25)
lib/search.hlthe search (#14): the in-memory index (ensureIndex, indexShow, searchTitleChanged/Merged/Renamed), dbSearch, TMDB webSearch, importTitle, titleIdByTmdb/Tvmaze
lib/search-helpers.hlthe search's text work: words, bucket keys, queryOfParam (the address decoded safely), ranking, years, SLUGS (slugBaseOf, normalizeSlugText — also people, merge, franchises)
lib/people.hlpeople (#16): by slug, facts, filmography, the step-by-step TMDB fill (personFillStep), cast people (castPersonId, #26/#28), guests on first open (guestPersonId, mission 018), /people's cached view + the photo of a tile/search row (peoplePage, personPhotoOf, #37)
lib/credits.hlcast + crew of a title (#28): linked to our people (mergedCast, linkedCrew, withCredits), applyCredits (search import), the credits job's creditsQueue/creditsStep, persistCredits
lib/credits-helpers.hlTMDB's credits answer read into ours: castOf, crewOf, roles, episode counts, crew jobs, hasCredits
lib/details.hla title completed from TMDB (#26): needsDetails, the repair queue, completeStep (details + cast, then a series' parts); the record's own fields withDetailsFields + applySynced (#18, the daily sync)
lib/tmdb.hltalking to TMDB and TVmaze: tmdbGet, the pacing (msPerRequest, pauseMsAfter), syncEnabled, TVmaze lookups + episodes, externalIdsFor (#12), syncPoster, adultOf
lib/sync.hlthe TMDB sync of ONE title (#9): syncShow, syncShowWith/syncShowPart (#26), syncShowDelta (#18), syncTvmazeStatus (#35), syncMovie, the TVmaze merge mergeTvmaze (#15), persistSync
lib/sync-helpers.hlthe sync's small pieces: placeholder titles, day numbers, stripHtml, numberingAgrees, season batches, migrated rows, run totals
lib/dailysync.hlwhich titles a day (#18): the change lists (planStart/planStep/planIds/planLine), sync-state.txt, isFollowedInRun, syncRunStep (which step a title gets, #35 status-only); the one-time moveCopiedSummaries
lib/backfill.hlthe adult-flag backfill (backfillTitle, mission 010), the kind backfill (kindTitle, #21) and the TVmaze status backfill (tvmazeStatusTitle, #35) — one title per step
lib/shortids.hlshort ids (#27): old ones from data/old-short-ids.json, the map over shows + persons, claimShortId, the backfill batch, shortIdPath
lib/franchises.hlfranchises + timelines (#19): tables, byShow, the pages' data, the widget (widgetsOf), the editor (isEditorSession, create/move/add/remove), titleMerged — docs/franchises.md
lib/collections.hl#19: timelines from TMDB's movie collections — the seed (seedPrepare/seedStep, collectionchecks.db)
lib/franchiseseed.hl#40: the curated franchises from data/franchises.json — prepare (queue of missing titles), one import per step, the build (adopt / keep / update), franchiseseed.db
lib/merge.hlduplicate titles merged (tracker#31, mission 026): queue, keeper, moving follows/watches/seasons/credits, tombstones
lib/jobs.hlTHE BACKGROUND JOBS (a class, one instance made in project.hl): the daily sync run (syncTick), adult + kind backfills, details repair, credits job, merge, short-id backfill, collection seed — their queues, pauses, log lines, switches (TRACKER_*, "Config") and clocks
lib/images.hl/posters/<name>, /profiles/<name>: the file or the placeholder (imageResponse)
lib/export.hlreading the old tracker's mongo export (the one-off tools migrate.hl, old-short-ids.hl)
components/styles.hlthe app's CSS: inherits theme/theme.hl, sets the accent (green-dark) + type colours, the tracker's own rules (mission 031)
theme/the WorldAPI theme, copied verbatim from layouts.worldapi.org (theme.hl, shell/*.hl header/menus/sidebar/shells, check-theme.mjs) — re-copy, never edit
.check-theme-ignorethe paths theme/check-theme.mjs skips (the copied pagination + modal)
shared/tokens.hlthe WorldAPI tokens — byte-equal to layouts.worldapi.org's shared/tokens.hl
login.jsbridge between <ident-selector>'s ident-login event and the shell, copied verbatim from calendar.worldapi.org
components/home.hl/ (#13): signed in the icon tiles (5 since mission 022 (old 068)); the text; the latest 10 movies, series and shows (catalog.hl)
components/movies.hl, components/allseries.hl, components/allshows.hl/movies, /series, /shows (+ /page/:page): every movie / series / show, 24 per page (#13, #21)
components/tilelist.hlthe poster grid + pagination both lists compose; turns a pagination click into navigate() (#13)
tests/pager.mjs + tests/seed-pager.hl#38 gate: every paginated list, 11 pages, real clicks + Back/Forward, phone + desktop (deploy.sh GATES)
tests/repro-t38.mjs#38, NOT the gate: node tests/repro-t38.mjs <base url> <width> — real clicks on /movies of any server (also live, read-only), prints address + first tile + current page per click
components/pagination.hlthe 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); watched count + check per watched movie (#24)
components/genre.hl/genres/<genre> (+ /page/<n>): a genre's movies and series, newest first, 24 per page (#25, mission 014 (old 058))
components/main.hlthe shell (#32): the theme's header (brand, search → /find, main menu, user menu / signed out the ident menu with <ident-selector>), the phone sidebar, main { ContentFirst { slot } }; the offline note + its netProbe tick (#10)
components/genres.hl, components/people.hl, components/settings.hl#32: /genres; #37: /people (+ /page/<n>): everyone, last updated first, photo tiles; /settings: the heading only
components/loginfailed.hl/login/failed: why a login didn't work
components/show.hl/series/:slug, /movies/:slug, /shows/:slug (via showsRoute): the title page, heading "<Type> \<title>" (#20) (header, Follow toggle, seasons with caret, episodes, watch check; signed out the sign-in modal)
components/modal.hlthe 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/series and /my/shows (/my/:tv, #21): followed series / shows (#13: movies → /my/movies), newest follow first — small poster, title, last watched SxxEyy
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)")
docs/kinds.mdmission 022 (old 068): kinds, the kind backfill, addresses, the /shows/<slug> function route, heading colours
tests/realdata-m031.mjsmission 031: real-copy screenshots at 390/1280 signed out/in + the header states (not a gate)
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)
components/person.hl#16: /person/<slug> — photo, facts, bio, filmography; "Loading filmography…" + the fill without reload
data/franchises.json#40: the curated franchises + timelines (TMDB ids, order kind, collections) — the creator's to correct; deployed with the code
data/old-short-ids.jsonmission 016 (old 060): the old tracker's 702 short ids (oldId → id), made by tools/old-short-ids.hl; deployed with the code
components/franchises.hl, components/franchise.hl#19: /franchises; /franchises/<slug> + /timelines/<slug> (one component, sort toggle client-side)
components/franchisewidget.hl#19: the `‹ PrequelTimelineSequel ›widget, composed bycomponents/show.hl`
components/franchiseedit.hl, components/timelineedit.hl#19: the creator's editor (/franchises/edit, /timelines/<slug>/edit)
docker-compose.yml, Dockerfile, deploy.shByrodin container; the deploy from Loreana (section "Deploy")

Tests and tools

File
tests/peek-shows.hl#18: the gate reads a title's description fields (TRACKER_PEEK; mission 023 (old 070): + migratedTitle/migratedSummary/migratedGenres count, and a title's episodes with TRACKER_PEEK_EPISODES), or clears summary + tmdbSummary of one (TRACKER_CLEAR_TEXTS) — app stopped
tools/count-migrated.hlmission 023 (old 070): how many shows/seasons/episodes keep a migrated value the sync replaced (migrated*) + example titles — ONLY on a copy or the stopped app
tools/ref-params.py, tools/lambda-audit.pymission 023 (old 070) (hybriel#48): python3 tools/ref-params.py . [--apply] gives every read-only lambda parameter &; python3 tools/lambda-audit.py . lists the lambdas that change a parameter (a copy since 8efba065)
tests/realdata-070-{run,sampler,load200,bench}.shmission 023 (old 070), NOT the gate: the app (or the old binary's tree) on a COPY under .scratch/w070/real-<name>/ with all jobs + a delta run, RSS every 10 s, 200 signed-in loads, a fresh-process bench (STATUS.md mission 023 (old 070) "How to repeat")
tests/realdata-070.mjsmission 023 (old 070), NOT the gate: on a COPY of live storage/ after the jobs (STATUS.md mission 023 (old 070)), signed in, 390/1280: every page kind (lists, /franchises, a franchise + timeline, a movie with the widget, a series with cast, a show, /my/*, search) — full load, heading, sideways scroll, screenshot, console problems
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)
tests/browser.mjsthe gate (above)
tests/kinds.mjs, tests/seed-kinds.hlmission 022 (old 068): the gate of #20/#21 and its TV fixtures (Talk/Reality/News genres, alice's follows)
tests/realdata-068.mjsmission 022 (old 068), NOT the gate: on a COPY of live storage/ after the kind backfill: spot checks by name (search label + link), every page kind at 390/1280 signed in (time, heading, sideways), screenshots
tests/seed-adult.hlmission 010 (old 054): the gate's adult/unknown-flag titles + alice's follow of the adult one, written with the app stopped
tests/seed-reduced.hlmission 029 (#33): 2 never-synced migrated titles (series 1401, movie 1402) + a synced movie without a poster (1403)
tests/seed-status.hlmission 030 (#35): 4 synced series with TMDB statuses and TVmaze ids 5501/5502/5504 (404)/none for the status backfill + pills
tools/count-incomplete.hlmission 029 (#33): reduced (incomplete) titles by type/origin and what their pages miss; reduced titles: N; --list — ONLY on a copy or the stopped app
tests/realdata-m029.mjsmissions 029/030, NOT the gate: paths at 390/1280, signed in/out on a copy server → screenshots, text, poster/Follow boxes, pill colours
tests/seed-tvleftover.hlmission 025: a movie carrying a TV answer's fields (status Ended, counts) for the repair check
tests/realdata-074.sh, tests/realdata-074-bench.mjsmission 025: real-copy server (jobs/fresh/stop, tree + copy as args) and the /my/ bench — NOT gates (STATUS "How to repeat")
tests/seed-show.hlwrites 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.mjsstarts a throwaway ident copy for the gate, copied from calendar.worldapi.org's tests/
tests/faketmdb.mjsthe 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/tvmovie/changes(paged,changesFail), /tvmaze/updates/shows, /tvmaze/shows/<id>?embed=episodes+summaries`
tests/realdata-check.mjsNOT the gate: the real-data browser check on a COPY of live storage/ (STATUS.md, mission 005 (old 047) / #13): times of every page incl. /, /movies, /shows, /my/movies, pagination clicks + Back, client-side navigation, screenshots to $REAL_SHOTS; env REAL_PORT / REAL_CHROME / REAL_OUT / REAL_SHOW (#15)
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.mjsmission 012 (old 056), NOT the gate: the merged app on a COPY of live storage/ after the adult backfill (STATUS.md mission 012 (old 056)): every public page type signed out + /my/* signed in at 390/1280 (times, header one row, screenshots), all list/search/person links → slugs.txt, an adult page signed out/in
tools/count-external-ids.hl#12: how many shows have each external id, per type, all / followed — ONLY on a copy
tools/count-tmdb-ids.hltitles sharing a TMDB id, movies with a TV answer's leftovers — ONLY on a copy (mission 025)
tools/count-duplicate-ids.hlduplicate @ids per table — ONLY on a copy (hybriel#113 check, mission 005 (old 047))
tests/realdata-058.mjsmission 014 (old 058), NOT the gate: on a COPY of live storage/ (STATUS.md mission 014 (old 058)), signed in, 390/1280: full loads of a show, a movie, /genres/<g> + page 2, /my/movies; air dates, season check == its episodes, genre kinds, one movie-check click + reload (and back)
tools/old-short-ids.hlmission 016 (old 060), one-off: the old export's Show.jsonl → data/old-short-ids.json (reads only the export)
tools/count-short-ids.hlmission 016 (old 060): short ids counted (with/without, old kept/lost, duplicates across shows + persons) — ONLY on a copy or app stopped
tests/seed-shortids.hlmission 016 (old 060): the gate's resume/duplicate fixture (60 persons without a short id, 2 sharing aaaa1), written with the app stopped
tests/realdata-063.mjsticket #29, NOT the gate: on a COPY of live storage/ (STATUS.md ticket #29), signed in, 390/1280: every check's class + computed look on /my/unwatched, a show page, /my/movies; screenshots; one /my/unwatched click + reload
tests/realdata-060.mjs, tests/realdata-060-backfill.shmission 016 (old 060), NOT the gate: on a COPY of live storage/ (STATUS.md mission 016 (old 060)): page times + short id under the image + /<id> redirects at 390/1280; the backfill run timed with curl
tests/realdata-057.mjsticket #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
tests/realdata-062.mjsmission 018 (old 062), NOT the gate: on a COPY of live storage/ (STATUS.md mission 018 (old 062)), signed in, 390/1280: a series + a movie (full load, HTML size, cast/crew/guest counts, screenshots), a guest star click → its new person page (time, screenshot), then 6 watch + 2 follow clicks on Picard and The Simpsons (ms + bytes received each)
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.hlmission 012 (old 056): the slugs tests/realdata-056.mjs collected → adult true/false/unknown counts, a user's followed adult titles, poster files of adult vs other titles — ONLY on a copy
tests/franchises.mjs, tests/seed-franchises.hl#19: the franchise gate + its 7 collection movies
tests/franchiseseed.mjs, tests/seed-franchiseseed.hl#40: the curated-seed gate (own data file, fake TMDB titles to import) + the creator's own franchise/timeline fixture
tests/realdata-m034.sh#40, NOT the gate: a tree on a COPY with every job off but the franchise seed (with a .env link it imports) — STATUS mission 034
tests/realdata-066.mjs#19, NOT the gate: on a COPY after the seed: /franchises, matching timelines (REAL_MATCH), a movie's widget, the creator's editor; times + screenshots
tests/seed-dupes.hl, tests/paths-m026.hlmission 026 gate fixture (Dupe Gate Series ×3, Dupe Gate Movie ×2, Stray Gate Series with stray seasons) and the creating-paths tool (search import + filmography fill against the fake TMDB, app stopped)
tests/realdata-m026.sh, tests/realdata-m026.mjs, tests/realdata-m026-counts.hlmission 026, NOT the gate: start this tree on a COPY with every TMDB job off; the creator's pages (Reacher, Archive 81, /my/*) + screenshots; the before/after numbers (STATUS mission 026)
tests/cdp.mjs, tests/ports.mjsthe CDP browser driver, copied from ident.worldapi.org/tests/
tools/migrate.hlone-off: old MongoDB export → storage/mpackdb/ (step 2, idempotent, see STATUS.md)
tools/verify.hlone-off: proves the migrated data against a COPY of storage/mpackdb/ (counts, zero dangling references, one show end to end)
tools/sync-tmdb.hlthe 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.hlone-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)"
tools/let-audit.pymission 028: which lets may be plain declarations (--list the kept ones with the reason, --fix rewrites) — README "Code order"
tests/realdata-m028.sh, tests/realdata-m028.mjsmission 028, NOT the gate: a tree (git archive + bin/, no .env) on a COPY with every job off; every page kind + the 301s/404s fetched signed in as az5b2 (raw + normalized HTML, the hydrated text) and full browser loads timed — two runs are compared with diff (STATUS mission 028)

Vendored Hybriel

bin/hybriel + plugins/ = hybriel master 06617221 (mission 027, 2026-10-03; sha256 af6dfcea08a3ae13e6ab4d6eeaada3609630fa739d92fab49c0095f05c733400): the plugin allocator fixes 3a781359 + 413f60e4 (#126, every plugin allocates through plugin_api's malloc) + mpackdb frees per operation (2cb7ae5e)

  • event order by queue stamp (f0ac2d2d, new plugin ABI field — copy ALL plugin .so together). Real copy: RSS through the first-start jobs
  • 400 loads flat at 2.54–2.59 GB (190aa11d: 2.3 → 5.6 GB), page times equal or better (STATUS mission 027). No lambda change. The previous vendor 190aa11d (sha256 feb597c4…, deployed with dc40d85) is in .scratch/pre-027/. Older history: 8efba065 (mission 023 (old 070), 2026-10-02; includes #126 f685f240 — the GC also collects by bytes, HL_GC_BYTES — and #48 4371b7aa — a lambda PARAMETER copies its argument, &p = a reference; also #112 #117 #119 #120, nothing to adopt), bin/hybriel sha256 50361e954e317a819aa58adfc2e3553f0b4b7adc82abd1fad79385d33b3d5b72. No local patch. #48 in this app: every lambda parameter a body only READS is declared & (319 of them, by tools/ref-params.py; tools/lambda-audit.py lists the ones that change a parameter; the copy per call made /my/schedule 5× and /my/unwatched 2.5× slower). The 13 lambdas that change a passed record/list (withCredits, withDetailsFields, addSum, firstStep, storeOrder, startFill, fillStep, mergeTvmaze, syncMovie, addTotals — mostly through a local let x = p) keep the copy: every caller uses the RETURNED value (audit: STATUS mission 023 (old 070)). A NEW lambda: & for a record/list/map it only reads. The previous build (8590df63, sha256 ca830ef8…) is in .scratch/pre-070/. Candidate not adopted (mission 024 (old 071)): master 190aa11d (sha256 feb597c4…) in .scratch/w071/vendor-190aa11d/ — to adopt, copy its bin/ + plugins/ over these, run the 3 gates (green on it 2026-10-02); .scratch/pre-071/ = 8efba065 again. Measure big pages old vs new with tests/realdata-071.sh
  • tests/realdata-071-bench.mjs (STATUS mission 024 (old 071) "How to repeat"). Built read-only from an archive (never in the hybriel checkout; the same with hybriel-047 → hybriel-056 for the older build):
git -C /media/STORAGE/projects/hybriel archive master | tar x -C .scratch/hybriel-047
cd .scratch/hybriel-047/native && /media/STORAGE/projects/hybriel/native/zig-toolchain/zig build -Dtarget=x86_64-linux-gnu.2.39 -Doptimize=ReleaseFast   # → zig-out/bin/hybriel (hybriel README + ReleaseFast)
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

Older builds: .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.

History and worker briefs

  • LOG.md — append-only history, one dated line per step (moved here from the antcolony LOG on 2026-10-01).
  • missions/NNN-*.md — worker briefs for this app; reports/NNN-*.md — their reports (same name). Numbered per project since 2026-10-01 (antcolony#40); older text, code comments and commits use the old antcolony numbers → map: /media/STORAGE/projects/antcolony-docs/docs/mission-map.md (Byrodin: /CONTAINERS/projects/antcolony/docs/mission-map.md).