# Release 8 — video titles (+ follow-ups) `main` at `bb3dbb4c`, release 7 live 2026-09-25 13:11; release 8 followed the same afternoon, starting with the operator's video-titles ask. Rules: `plans/tools/implementer-rules.md`. Record file: this file. ## Record ### Slice V, as shipped — video titles (2026-09-25) Branch `one-core/r8-video-titles` off `main` `bb3dbb4c`. The operator's ask (2026-09-25): "In the editor, the individual video view should show video metadata like the title, and `/videos` should show titles in the list when available and allow searching by them." There is no global `/videos`; the list is the per-channel workspace `editor/app/channels/[slug]/videos/page.tsx`. Before this slice a list row was built from `ChannelSnapshot` id lists and showed only the id, the search box matched only the id, and the video page read `metadata.info.json` with its own parser, so a video that was never downloaded showed a bare id even when the metadata scan knew what it was called. Three places hold a title, and the slice reads them cheapest first. The first one found wins: - **`index`**: the LMDB `sums` sub-DB, reached by a key-only walk of this channel's `byChannel` range. A summary is decoded only for an id the list asked about. It is opened `readOnly` **with `compression: true`**, for the reason `curatedTagsPreview.ts`'s `openIndex` gives: without it, any value over ~1 KB throws. A missing or unopenable index gives no titles and is not an error. An index title equal to the id is `summarize()`'s fallback, not a title, and is skipped. - **`scan`**: `channels//metadata-scan.json`, read once through `loadMetadataScan`. - **`metadata`**: `data//metadata.info.json`, only for ids the first two did not name. It is a 16 KB head read plus a regex (yt-dlp writes `id` then `title` first). The full parse (`loadRawMetadataFromDir`) runs only when the head has no title. 16 reads run concurrently. Nothing creates `data//`, which keeps the scan store's invariant. `metadataScanStore.ts` and `channelSnapshot.ts` are unchanged. | sha | what | |---|---| | `9ed6cce8` | `common/controller/videoTitles.ts`: `readChannelVideoTitles(paths, slug, ids) → Map` and `readVideoMetadataForDisplay(paths, slug, id) → {title?, description?, webpageUrl?, uploader?, uploadDate?, duration?, source: "metadata"\|"scan"\|"none"}`. `.test.ts` has 6 cases on a real compressed LMDB with a 4 KB description: the three-source merge with first hit winning, bare ids absent and another channel's id not leaking; missing index and scan store; the id-as-title fallback skipped; the head read with an escaped title, a title past 16 KB and no dir created; the display reader's three outcomes; and the 5,000-id cost case | | `cdd6c3dc` | The list. `VideoRow.title?`. `computeVideoRows` takes an optional `titles` map, which the page fills once from `readChannelVideoTitles` over data-dir ids ∪ `undownloadedIds`. `matchesVideoQuery` (id OR title, case-insensitive) is used by both the client filter and the server's `?q=` ordering for prev/next. A row shows the title with the id on a muted mono line under it, and shows the id alone when there is no title. **The row's accessible name stays `open `**, and the checkbox stays `select `. The placeholder is now "Search title or id…". The empty copy ("No videos match this filter.") names no ids and is unchanged. The embedded detail pane's `video title` line reads the same map, and the page's private `loadVideoTitle` parser is gone | | `16ef0758` | The video page. `loadMeta` is now `readVideoMetadataForDisplay`, and the page's private parser is gone. When the source is `scan`, the header shows the scan's title, upload date and duration, plus an italic note "from the listing scan — not downloaded" (`aria-label="metadata source"`). A collapsed `
` **Description** block appears when either source has a description. `generateMetadata` behaves as before (title, else id) and now also finds scan titles. New `editor/e2e/video-titles.spec.ts` (2 tests) on `youtube-with-playlist` plus a seeded `metadata-scan.json`: `fake00000001` shows its `metadata.info.json` title, `fake00000002` its scan title, and `fake00000003` its bare id. "harbor" leaves one row, and the id still matches. The undownloaded page shows the h2, the scan note, `2024-03-15`, `12:34`, and a Description that is collapsed and opens on click. The downloaded page has no scan note | | `ff3944cd` | this record and two `[Unreleased]` bullets in `editor/CHANGELOG.md` | | `4e8bb285` | **Review M1.** Source 3 is memoized per channel in `videoTitles.ts` (`globalThis.__yttVideoTitleMemo__`: `Map`). Each render does one `stat` of `data/`. A changed mtime (a video dir added or removed) drops the channel's entry. Only ids missing from the memo are read, and misses are not memoized, so a dir still being downloaded picks up its title once the file lands. The memo holds at most 64 channels, least recently used evicted first. `resetVideoTitleMemo()` is called by `api/test/invalidate-cache` alongside the other process memos. `videoTitleMetadataReadCount()` is a test hook. New unit case: with `data/`'s mtime unchanged, the second call reads 0 files and still serves a title whose file was deleted; a new dir makes it read all 3 again; a miss is re-read later | | `41facfe0` | **Review L1 + L2.** `matchesVideoQuery` moves above `parseFilters`' doc comment. `editor/app/channels/[slug]/lib/videoRows.test.ts` (3 cases) covers id hits, title hits (case-insensitive), a row without a title, and a blank query. This pins the server `?q=` path, which shares the predicate with the search box | | *(this commit)* | the re-gate below and the corrected cost paragraph | **Gates** (worktree root, on `16ef0758`). tsc (`pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`) was clean before each commit. common **1801/1801** = 1795 + 6 (`videoTitles`). Editor unit **72/72**. test:scripts **161 pass + 1 skip**. mcp **219/219**. `pnpm --filter editor exec next build` ok. The export build was not run, because the slice touches no file under `export/` and no `common/` module it imports. EDITOR e2e used `$T/v-specs.txt`. `videos.spec` and `channel-page.spec` do not exist, so the list is `video-titles`, `video-page`, `video-filter-combine`, and the six specs that drive the list or the embedded pane (`channel-embedded-video`, `bulk-actions`, `incomplete-transcript`, `download-format-guard`, `channel-storage`, `whisper`). Result (`v-e2e.log`): **62 passed, 0 failed, 4.0 m**, after a 2 m 38 s queue wait behind `one-core/r8-state-share`. The run used this worktree's block (`PORT=4011`, `EXPORT_PORT=4010`); the `PORT:3011` printed by the e2e script is a default for when the env var is unset. Every heavy step started with ≥ 3 GB available. **Re-gate after the review fixes** (on `41facfe0`). tsc was clean. common **1802/1802** = 1801 + 1 (memo case). Editor unit **75/75** = 72 + 3 (`videoRows.test.ts`). `pnpm --filter editor exec next build` ok. The memo touches the e2e reset route, so the EDITOR e2e re-ran the full nine-spec list (`v-e2e2.log`): **62 passed, 0 failed, 3.6 m**, with no queue wait. test:scripts and mcp were not re-run, because neither imports anything that changed. **Numbers: none.** No `settings.json`, `site.json` or `config.json` key changed, and nothing is written: the slice only reads. **Cost of the title map** (the brief's bar: "does not change the page's order of magnitude"). The first version held that bar only for a YouTube-shaped channel, where most titles come from the index. The review found the Rumble case. On Rumble every directory name misses the index, so every row fell through to a `metadata.info.json` head read, on **every** render. Each row click re-renders, because rows are `?video=` links on a force-dynamic page. `the-quartering-rumble` has **8,049** dirs. The reviewer replayed the read pattern read-only and measured **5,024 ms cold / 224 ms warm per render** before the memo. After the memo (`4e8bb285`), measured read-only with `readChannelVideoTitles` itself over the live `data/` (no index opened, `$T/v-memo-measure.mts`): the **first render took 5,331 ms** (cold page cache, all 8,049 titled) and **memoized renders took 5.5 ms and 6.5 ms**. The cold first render is still paid once per editor process, and again after a video dir is added or removed on that channel. In the unit test's synthetic 5,000-id mix (3,000 index, 1,500 scan, 400 `metadata.info.json`, 100 bare) it is 82–148 ms first and ~75 ms memoized. That run is dominated by the index walk and the scan store, which are not memoized: one LMDB range and one JSON read, not N file reads. **The memo's one blind spot, accepted.** A `metadata.info.json` rewritten inside an *existing* video dir does not change `data/`'s mtime, so the list keeps the old title until the editor restarts or a video dir is added or removed. A video's title does not change after download, so this was accepted. **Found and left.** - **Coverage is partial on `paramount-tactical`.** A read-only `jq` over live `snapshot.json` / `metadata-scan.json` found one channel with undownloaded ids: `paramount-tactical`, with **1,439** and no `metadata-scan.json`. Those rows show ids until a metadata scan runs, which is the same step slice K's `keep-videos` needs. Every other channel's list is fully downloaded, so it is titled from the index and `metadata.info.json`. - **The index is keyed by metadata id and the list by directory name.** On Rumble (URL-slug dirs, embed-id metadata) and on legacy `YYYYMMDD_` dirs, the index lookup misses. Those rows are titled from `metadata.info.json`, and the memo above now carries that cost. Matching dir names to index ids (for example through the `mtimes` sub-DB's `[slug, videoDir]` keys) would make the cold first render cheap too, but is left for later. - **L3 (review, left).** When a previously scanned video has a `data//` dir but no readable `metadata.info.json` (a failed or partial download), the page falls back to the scan entry and says "not downloaded" beside a files panel. This is rare, and the wording was left as it is. - **Rows stay sorted by id**, as before. Sorting by title or date is a separate ask. - `VideoListPane.tsx` lives at `editor/app/channels/[slug]/components/`, not under `videos/**`. The brief names "the list pane" in ownership, so it was edited as in scope. `ROW_ESTIMATE_PX` (30) was left alone. Titled rows are about 40 px, and the virtualizer measures each row, so only the first scrollbar estimate is off. - The stage lists (`VideoIdList.tsx`, on the download/transcribe stage panels) still show bare ids. They are not the `/videos` list, and titling them is outside this slice. - **Commit trailers** name `Claude Opus 5.5 (1M context)`, as the release-6 and release-7 implementers did. ### Slice S, as shipped — one shared auto-queue state per process (2026-09-25) Branch `one-core/r8-state-share` off `main` `eff6a3a7`. **The bug, live on 2026-09-25.** `sharedAutoQueueState` (`common/controller/autoRunner.ts`) is meant to give ONE `AutoQueueState` object to all four lane runners, because `writeAutoQueueState` serializes the WHOLE `.auto-queue/state.json` (all four lanes). It cached only the RESOLVED value. At boot, `startAutoRunnersIfEnabled` awaits only each lane's *registration*: `runManagedFunction` fires `runLoop` without awaiting it. So all four runLoops reached `await readAutoQueueState` before any of them set `singleton.state`. Each got its own copy and kept it for its lifetime, since `state` is captured once per runLoop and every `persist()` reuses it. The live sequence ran as follows: - **13:10–13:11** boot. Four private copies were made, all reading `download.platformBackoff.youtube = {fails: 12, until: 13:20}` and `videoDeferrals: {}`. - **13:20** the download lane persisted `fails: 13`, `until: 13:52` and a 6 h deferral for `_60iFE_FBPQ`. - **13:29** the operator renamed `paramount-tactical-videos` to `paramount-tactical`. The priority recompile re-shaped the other lanes' trees. The rename does not touch this file. - **13:33:37** another lane picked work and persisted its boot-time copy, which set the file back to `fails: 12` and deferrals `{}`. The pacing was silently undone on disk. The rename is only what made another lane persist at that moment. The race is what made the write wrong. **The fix** caches the in-flight read, keyed by the state file. Every caller in the same window awaits one `readAutoQueueState` and gets the same object. A failed read is never cached, so the next caller retries. A read that a call for a different file supersedes resolves only to its own awaiters. The e2e harness reset (`api/test/invalidate-cache` sets `__yttAutoRunner__ = undefined`) replaces the singleton wholesale, so a read still in flight lands in the old singleton and cannot leak into the next spec. The big comment above the function is kept, and a paragraph with this incident is added. `sharedAutoQueueState` is now exported for the tests. There is no restructuring, no file-format change and no change to `writeAutoQueueState`. **The same bug in the other direction, fixed in review.** `recordDownloadBackoff` (`common/jobs/downloadBackoff.ts:31-43`) records a 429 hit by a manual Sync, a metadata scan or the video page. It was a disk-only read-modify-write while every runner held the shared object in memory, so the next save by any lane erased it. The download lane's merge-from-disk at the top of each iteration did not help while that lane was paused, stopped or at capacity, which is when an operator is riding out a rate limit. The holder and `sharedAutoQueueState` moved to `jobs/autoQueueState.ts` on their own global, `__yttAutoQueueState__`. `jobs/` may not import `controller/`, and `controller/` importing `jobs/` is allowed, so `architecture.test.ts` is unchanged. `autoRunner.ts` re-exports `sharedAutoQueueState`. A new `liveAutoQueueState(paths)` returns the live object, or the in-flight one, without starting a read. `recordDownloadBackoff` uses it: when a runner is live, it merges the disk backoff in (the later `until` wins, as the runner's merge does), escalates on the live object and writes through it. With no runner live it keeps the disk round trip. `platformCooldownRemainingMs` and the status builder's `computeLeafPending`, which only clones, prefer the live object. The e2e reset (`api/test/invalidate-cache`) clears the new global beside `__yttAutoRunner__`. | sha | what | |---|---| | `135dbebd` | `autoRunner.ts`: `AutoRunnerSingleton` gains `loading` / `loadingFile`, and `sharedAutoQueueState` caches the promise (exported, with the incident paragraph). `autoRunner.test.ts` has 4 cases: (a) four concurrent calls (the boot fan-out) resolve to the same object, and a later call gets it too; (b) a mutation through one caller (backoff `fails: 13` plus a video deferral) is visible to the other before any write; (c) the write seam: the download lane escalates the backoff and writes, then another lane mutates the digest runtime and writes *its* reference, and the file on disk carries both; (d) a reset singleton reads afresh, and two files in flight at once stay two objects. Temp dirs, no module mocks. **Cases (a)–(c) FAIL against the pre-fix behaviour** (checked by disabling the in-flight branch: 3 failed) and pass with it | | `755e137b` | the `[Unreleased]` bullet in `editor/CHANGELOG.md` (this record is pasted into `plans/release-8.md` by the parent at merge) | | `4cfc7491` | (review fix) The holder and `sharedAutoQueueState` move to `jobs/autoQueueState.ts` (`__yttAutoQueueState__`, plus `liveAutoQueueState`), and `autoRunner.ts` re-exports them. `recordDownloadBackoff` merges and writes through the live object, and `platformCooldownRemainingMs` and `computeLeafPending` prefer it. `invalidate-cache` clears the new global. `downloadBackoff.test.ts` gains 4 cases: with a runner live, another lane's persist keeps a manual cooldown on disk; a cooldown already on disk is merged in, not dropped; the remaining time prefers the live object; with no runner live, the call stays a disk round trip and starts no read. **The first three FAIL on the pre-fix `downloadBackoff.ts`** (checked: 3 failed) | | `89a7d6ab` | the CHANGELOG clause for the cooldown fix | **Gates.** First run, on `135dbebd`: tsc clean; common **1799/1799** = 1795 + 4; editor unit **72/72**; `pnpm --filter editor exec next build` ok. EDITOR e2e, `auto-queue.spec.ts lane-runner.spec.ts pacing.spec.ts queues.spec.ts` (`s-e2e1.log`): **38 passed, 0 failed, 4.0 min**, no queue wait. Re-gate after the review fix, on `4cfc7491`: tsc clean; common **1803/1803** = 1799 + 4; editor unit **72/72**. The first `next build` attempt failed on a Google Fonts fetch (`next/font/google` module not found). The retry was ok, with no code change. EDITOR e2e, the same four specs plus `rumble-sweep.spec.ts`, which seeds `platformBackoff` (`s-e2e2.log`): **39 passed, 0 failed, 4.0 min**, no queue wait. The export build, test:scripts and mcp were not run, because the slice touches no file under `export/`, `scripts/` or `mcp/`. Heavy steps started only at ≥ 3 GB available memory. **Numbers: none.** No `settings.json`, `site.json` or `config.json` key changed, and the state file's format is unchanged. **Found and left.** - **The status builder's disk read is kept as a fallback.** `computeLeafPending` reads `shared?.state`, then the live object, then the file. It only clones from it, so it is not a writer. - **An orphaned runLoop after an e2e reset (pre-existing, e2e only).** A spec-1 runLoop can still persist its old object to the same fixture file until it notices its job is gone. That is a cross-spec clobber, not a two-lanes-in-one-process problem, and this slice did not introduce it. ### Slice H, as shipped — the homepage, rethought (2026-09-25) Branch `one-core/r8-homepage` off `main` `07a56106`. **The operator's direction (2026-09-25):** "take a look at the homepage and try out a complete refresh, rethink it from the ground up. Primarily describe the project, and any of our existing sites should be regarded as the instances that belong to me, maybe called something like 'Official instances'." The page had "a bit of 'clunky AI' writing where it could be more straight to the point and have less subtitles"; "The whole 'What this isn't' section seems kind of bad"; "narrow it down to minimal most impactful copy and show some cool stats about the official instances"; and "The 'recent acquisitions' list doesn't feel like it should be the above-the-fold showpiece, I think a chart there would be a cooler visual." The hub link stays withheld (`HUB_LINK_ENABLED = false`). **The data.** `buildHomepageSummary` is v5, all additive and optional on the type so a v4 summary on disk still renders (its numbers hide): - `monthly: {month, bySite}[]` counts transcribed public-universe recordings by **upload month** (`VideoStat.uploadDate`), attributed to the primary public site. It is full history, from the earliest upload month to the last complete month (the build month is never emitted), zero-filled, and every site is keyed in every month. Upload month rather than `transcribedDate` because the transcription history is six months old (2026-04 to 2026-09, 25,288 of it in May) and would chart the tool's own backlog. Upload month shows how far back each archive reaches: 204 months, 2009-09 to 2026-08. - `monthlyUnplaced` counts the transcripts with a missing, malformed, build-month or future upload date, so nothing is dropped silently (106 on the current corpus, all from the build month). - Each site gains `channels` (with a transcript), `recordings` (with a download date), `hoursArchived` and `gone` (status `deleted`). - `official` sums them over the public sites. It is not `totals`, which also counts pool-only channels and the URL-less Jasolyzer: 49,767 official transcripts against 49,769 in total. **The page, top to bottom, with its final copy:** 1. **H1** `{official.hoursArchived} hours of speech, searchable to the second.` (no data: `Every word a channel said, searchable to the second.`). It is one line from `lg` up. **Subhead:** "Download a channel's back catalogue, transcribe it on your own hardware, publish a static site you host yourself." The CTAs are unchanged: `Download the source` → `/downloads/`, `Read the setup guide →` → `/docs/install/`, and the hub button behind the switch. 2. **The chart** (`ArchiveGrowthChart.tsx`), in the hero above the fold at 1280×900. It has a legend row, then stacked strata, then the caption "Transcripts by the month each video was published, all official instances." and a `Numbers by year` details table. 3. **The stats strip** (`FamilyStats.tsx`): Hours of speech · Transcripts · Recordings · Channels · Official instances · **Recordings gone at the source, still here**. The last cell is double width, in `--state-gone`. The caption is `Across the official instances · index built