Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 9babe0ee3ff820042c1b64655f8e298ea1a60877
parent 9035703147877834e25046474645d95b1de140c0
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 16:22:45 -0400

plans: release 17 — the media tier (plan + record file); step 0a done

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Aplans/release-17.md | 326+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 326 insertions(+), 0 deletions(-)

diff --git a/plans/release-17.md b/plans/release-17.md @@ -0,0 +1,326 @@ +# Release 17 — the media tier: big files move, hot text stays on the SSD + +Written 2026-10-01 in plan mode against `main` `03be31b5` (releases 13–16 live). Rules: +`plans/tools/implementer-rules.md` — one Opus implementer per slice in its own worktree, one read-only +Opus review, the parent merges `--no-ff` on a clean tree; records state rulings never reasons; counts-only privacy greps before every merge; changelog bullets checked +by eye; the homepage build gate is `build:nodata`, never `run build`; the corpus link is for `next +build` only. The live editor on `:3001` is restarted ONCE, after T3 merges and BEFORE the migration +runs (the scripts live in `~/reports/release-15/scripts/r15-{build,restart,smoke}.sh`; the guard sha +there must be updated). + +## Context + +A "moved" channel today moves its whole `data/` directory to another drive (an absolute symlink +`channels/<slug>/data → <root>/<slug>/data`, `config.dataDir`). That puts the hot text — transcripts, +cues, `metadata.info.json`, every sidecar — on the slow platter, so every non-media operation on a +relocated channel (index and stats builds, the video page, digests, normalize) waits on it, and a +platter stall holds the whole channel. The big files alone belong on the slow drive and the text on +the SSD; the same split for umtool (manifests stay, renders go). 13 of 76 channels are relocated whole +today and are migrated by a one-off script. A channel export/import bundle is a LATER slice built on +this release's file classifier — not part of this one. + +**Measured 2026-10-01** (the 13 relocated channels, MB): media (`audio.*`, `source-media.*`) 323,000; +everything else 91,008 — of which raw `transcript.live_chat.json` 33,457 (rekietalaw 20,958, +friday-night-tights 7,460, kirsche 4,706) — and `clips/` 15,408 (nuxanor-kick 15,317). `/home`: 1.5 TB, +123 GB free, gate floor 5 GB, resume margin 2 GB. The index never opens a media file (presence by name +from one `readdir`; every `stat` is a sidecar). yt-dlp writes cwd-relative into `data/<id>/` and its +postprocessor, like the app's transcode, `rename()`s a temp file OVER the final name (a symlink there +would be replaced by a real file). The saved-video store (`saved-video.json` pointer, EXDEV-safe move) +is the existing per-big-object pattern; umtool's `SONG_DIR` is the existing symlink-root pattern. + +## Step 0 — the editor dashboard does not load (diagnosed 2026-10-01, read-only; fixed first) + +**Symptom:** `/`, `/channels`, `/jobs` give no response in 40 s; `/settings` answers in 1 s; `/api/pulse` +200; `/api/jobs/active` and `/api/auto-queue/status` never answer. **Cause (measured):** the editor's +main thread is pegged (~97 % of a core, 485 ticks/5 s; process 435 % across V8 threads; the Platter idle, +no errors in the log). `generateChannelSnapshot` (`common/controller/channelSnapshot.ts`) runs ON THE MAIN +THREAD with no yielding, driven by `common/jobs/snapshotScheduler.ts:192` as `refresh-report` jobs; two +live ones (omnibased 3,260 videos, the-quartering) have parsed thousands of ~500 KB `metadata.info.json` +files for over an hour, starving every request that needs the loop. The dashboard's 3 s poll of the +auto-queue status (no memo, no single-flight in `common/views/autoQueueStatus.ts`; 96 s cold this +afternoon) piles up behind it. Three more "running" `refresh-report` metas (the-quartering-rumble ×2 at +03:21/03:29Z, the-quartering 07:14Z) predate the 12:54 restart: ghosts of the killed process +(`common/jobs/shutdownCancel.ts:20` "graceful shutdown only"), never finalized — and slice RM's +`channelWriters` reads running jobs by slug, so a move of the-quartering-rumble would refuse forever. + +**0a — done 2026-10-01 16:18:** the editor was restarted (`r15-restart.sh --force`; the build on disk +was current, `Xfavvt2XajdNzOvvn0NtL`); `/` answered 200 after 3 s. The scheduler re-runs the two +snapshots; `/` watched for ten minutes after (result in the Record). +**0b — slice D0 `r17/dashboard-answers`** (small; beside T1; editor-side, one restart): +1. snapshot generation yields: chunk the per-video loop (`setImmediate` every N videos) so the loop + serves requests between chunks — or run it in a worker thread; `refresh-report` concurrency 1 and + dedup per slug in `snapshotScheduler.ts`; +2. `autoQueueStatus` view: single-flight + a short memo (≈ 3 s) so N pollers cost one digest; +3. boot finalizes stale `running`/`queued` metas from a dead process as `interrupted` (`registry.ts`, + `editor/instrumentation.ts` already notes "anything a previous process left queued was queued before + this instant"); `channelWriters` ignores them; +4. e2e: poll `/` and `/jobs` every 2 s while a large fixture snapshot regenerates — every response under + 5 s; a ghost `running` meta from a fake dead pid is `interrupted` at boot and does not block a move. +Gates: common + editor unit, `jobs`/`channels`/`channel-storage` specs, the capped editor build. + +## Rulings (2026-10-01; do not re-open) + +- **Tier now, bundle later.** The classifier is a shared `common/lib` module the bundle slice reuses. +- **Text = hot = SSD; media = cold = platter.** `transcript.live_chat.json` is COLD (media tier): read + once by `normalizeLiveChat`, which derives the small `live_chat.cues.json` (hot). `clips/` stays on + the SSD (a cache, age-evicted) — not tiered. +- **The whole-directory layout is retired** after the migration; `config.dataDir` is tolerated one + release as `legacy` (held) and removed later. +- **umtool:** render scratch (`out/`) goes to a media root by default; deliverables (`clips/`, + `share-*/`, final mp4s) move per project by a switch "like channels"; manifests, `revisions/`, the + caches and the cue cache stay put. +- **Migration:** one-off script, smallest text first, a stop before the three big-text channels + (omnibased, rekietalaw, the-quartering-rumble) with the free space reported; the operator decides. +- **A move holds only the channel's media writers**; a digest may run during a move. + +## The model (A′) + +``` +channels/<slug>/ + config.json mediaDir: "<root>/<slug>/media" (absent = in place) + data/ REAL directory on the SSD, always: text, sidecars, clips/, scratch + <id>/audio.mp3 -> ../../media/<id>/audio.mp3 (RELATIVE link, one per big file) + <id>/transcript.live_chat.json -> ../../media/<id>/transcript.live_chat.json + <id>/transcript.json, metadata.info.json, live_chat.cues.json, clips/… (real files) + media ONE absolute symlink -> <root>/<slug>/media (relocated) + | a REAL directory on the SSD (tiered in place) + | absent (classic: real files in data/<id>/) +<root>/<slug>/media/<id>/<file> (the bytes) +``` + +Why A′ over absolute per-file links or a pointer sidecar: readers keep opening `data/<id>/<name>` by +path (no reader changes; the index unchanged); the platter path exists in ONE link + ONE config key per +channel, so slice RM's mover — marker, writers hold, copy, mirror (`--delete` toward the copy only), +verify, Reconcile, re-point, rename, delete — carries over by renaming `data`→`media`; relative links +survive `reconcileVideoDirs`' renames and a channel rename; move back makes `media/` a real SSD dir and +every link stays valid (no "untier"). A classic channel keeps real files until its first move, whose +preflight tiers it in seconds (same-filesystem `rename` + `symlink`); e2e fixtures need no layout change. + +### 1. Classifier — `common/lib/mediaTier.ts` (pure, no fs; exported for the bundle slice) +- `classifyEntry(name) → "media" | "text" | "scratch"`, BY NAME never by size, over `mediaFiles.ts`'s + anchored predicates: media = `isRealAudioFile(name) || isSourceMediaFile(name) || name === + LIVE_CHAT_FILENAME` (`transcript.live_chat.json`); scratch = `isPartAudioFile`, `/^audio\.tmp-\d+\./`, + `/^\.audio\..*\.parakeet$/`, `/\.part-Frag\d+$/`, `/\.temp\./`, `/\.part\.(good|testing)$/`; text = + everything else. `CLIPS_DIR_NAME` is never tiered. +- `isTierable(name)` = `isRealAudioFile(name) || name === LIVE_CHAT_FILENAME` — narrower than "media": + `source-media.*` stays real (`persistSourceVideo` would `rename` the LINK into the saved-video store) + and `audio.*.part` stays real (yt-dlp's resumable partial). +- `classifyVideoDir(entries) → { media, text, scratch }`. + +### 2. Hook + helpers — `common/lib/mediaTier-server.ts` (fs, no controller import) +- `channelMediaLink(paths, slug)` = `channels/<slug>/media`; `relocatedMediaDir(root, slug)` = + `<root>/<slug>/media` (replaces `relocatedDataDir`, suffix fixed); `tierLinkTarget(id, name)` = + `../../media/<id>/<name>`. +- `tierMediaFile(videoDir, name) → "tiered" | "left" | "already"`: lstat a link or missing → already; + `channels/<slug>/media` not a directory (classic channel, or ENOENT through a dangling link when the + platter is unmounted) → "left" (the file stays real, readers unaffected, the next sweep tiers it); + `mkdir(media/<id>)` NON-recursive (a bare mountpoint is never filled); `rename` → EXDEV → + `copyFileAtomic` + rm (the `savedVideo-server.ts:70-80` pattern); `symlink(relative)`. Never throws + into a download. +- `tierVideoDir(videoDir)`; `tierChannelMedia(paths, slug, { since?, createMediaDir? })` (the mover's + preflight passes `createMediaDir: true`, making `media/` a real dir when neither link nor dir exists). +- `removeMediaFile(videoDir, name)` (readlink → rm target, rm link); `removeVideoDirMedia(videoDir)`. +- **Every media-finalising site calls the hook** (one line each): `common/controller/transcode.ts:55` + after `rename(tmp, out)` (covers `downloadOneManaged.ts:278`, `audioCheckedDownload.ts:769`, + `videoActions.ts:128`); `downloadOneManaged.ts` `tierVideoDir(videoDir)` before both + `writeDownloadOutcome` calls (`:1001`, `:1629` — after `reconcileVideoDirs`, covers yt-dlp's own `-x` + and the legacy path); `runYtdlp.ts` batch runs (sync, download-from-playlist, download-missing, retry) + → `tierChannelMedia(paths, slug, { since: runStartedAt })` after the child returns; `normalizeLiveChat` + → `tierMediaFile(videoDir, LIVE_CHAT_FILENAME)` after writing the cues (and the download path's + live-chat write). `persistSourceVideo`/`unpersistSavedVideo` unchanged; `fetch-window` → `clips/` no hook. +- **Every media deleter derefs** (today each would orphan the platter file): `cleanAudioFromTranscribed.ts:158`, + `cleanExtraAudioFormats.ts:75`, `removeWrongFormatAudio.ts:73`, `videoActions.ts:598`, `:490-503` + (`deleteFile`), `deleteVideoDirAction` (+ `removeVideoDirMedia`), `backfillReacquire.ts:506`. Grep gate: + `git grep -n "remove(path.join(.*videoDir\|rm(path.join(videoDir" common editor` hits only `mediaTier-server.ts`. +- One reader changes: `editor/app/channels/[slug]/videos/[id]/videoActions.ts` `loadVideoDir` filters + `isFile()` on dirents → `isFile() || isSymbolicLink()`, media entries stat'd through `onDrive(mediaDir)`. + `measureTree` keeps `isFile()` (over `data/` it measures SSD text; over `media/` the real bytes). + +### 3. `config.json` +- `mediaDir?: string` (`channelConfig.ts`, `channelConfigSchema.ts`, CHANNEL.md row): "Where this channel's + big files live when relocated: `channels/<slug>/media` is a symlink to it, `<root>/<slug>/media`. Absent = + in place. Written only by relocate / re-point / the tier migration." +- `dataDir` stays parseable one release, documented RETIRED: a channel carrying it (or whose `data/` is a + link) is `legacy` and held until `archilyzer storage migrate-tier <slug>` runs. (`parseChannelConfig` is + allow-list; dropping the key would make `patchChannelConfig` erase it on the first stamp → `inconsistent`.) + +### 4. Statuses, guards, lanes, health (`common/lib/channelMedia.ts`, `channelMediaHold.ts`) +- `ChannelMediaStatus` gains `"legacy"`; the others now describe `channels/<slug>/media` + `mediaDir`: + marker → `in-transition`; `data/` a link or `dataDir` set → `legacy` ("its media layout is the retired + whole-directory one — run archilyzer storage migrate-tier"); no media link/dir and no `mediaDir` → + `in-place`; link ≠ `mediaDir` or dir while `mediaDir` → `inconsistent`; `mediaDir` + link agree → + `stat(target)` via `onDrive(mediaDir)` → `ok | unreachable | stalled`. +- `ChannelMediaLocation`: `dataDir` (always the real text dir), `relocated`, `target` (= mediaDir), new + `mediaLink`, `text: { dir, readable }`. `channelMediaStall` keys on `mediaDir`; `storageHealth.ts:529` + `rootOfUnknownPath` strips `<root>/<slug>/media`. +- Two guards: `assertChannelMediaReachable` (unchanged: `ok | in-place` pass — jobs that open the big + file) and new `assertChannelTextReadable` (passes for all but `legacy`/unreadable `data/`). + `isMediaHeld` (+ `legacy`), new `isTextHeld = status === "legacy"`, `HELD_REASON.legacy`. +- `jobKinds.ts` `needsMedia` keeps its name with the doctrine "opens or writes the BIG file". Flip to + false: `auto-digest`, `digest-channel-local/remote`, `digest-share-cluster`, `normalize-transcripts`, + `purge-superseded-auto-subs`, `fetch-window`, `evict-clips`, `metadata-scan`, `download-missing-subs`, + `check-availability`, `quick-availability-check`, `check-maybe-missing`, `check-kept-deleted`. Stay + true: every download kind, `sync`, `import-one`, `redownload-*`, `retry-bucket`, `persist-kept`, + whisper/transcribe, `diarize-channel`, `backfill-channel`/`auto-backfill`, `scan-media*`, `clean-*`, + `remove-wrong-format-audio`, `transcode-audio`, and `normalize-live-chat` (reads the cold raw file). +- Walkers call the text guard: `buildIndex.ts` `scanSource`, `buildStats.ts:291` — **new rule: the index + and stats builds read the text tier only and are never held by the media tier** — `channelSnapshot.ts:744`, + `operationBatch.ts:1580`, `normalizeAll.ts:63`, `shardActions.ts:88`. Lanes (`autoRunner.ts:370-386,641`): + `isMediaHeld` for transcription/download/backfill, `isTextHeld` for digest. +- `onDrive` keyed by FILE KIND: media reads wrap `onDrive(config.mediaDir, …)`, text reads pass no drive. + Change: `channels.ts:108`, `channelSnapshot.ts:753` (sidecar stats direct; one `onDrive(mediaDir)` per + video for its media entries; `DriveNotAnswering` → that channel's media bytes `null`, snapshot still + written), `recencyIndex.ts:258`, `videos/page.tsx:153`, `videos/[id]/page.tsx:96,201`, the file route + (`classifyEntry(name) === "media"` → `onDrive(mediaDir, stat)`; **ENOENT on a path whose `lstat` is a + symlink → 503 "drive not answering/unmounted" with `retry-after`, not 404**). Stall budget: text never + takes a slot. `storageWatch.ts` auto-pause stays one tier per channel. +- `channelWriters(slug, { mediaOnly })`: jobs filtered by `kindNeedsMedia`, lane units from lanes ≠ + digest; the move's `liveWriters`/`previewRelocation`/`assertNoWritersUnderMarker`/`channelMediaBusyReason` pass it. + +### 5. The mover — `relocateChannelMedia.ts` over `media/` +- Marker `.relocating.json` unchanged shape, `target = <root>/<slug>/media`, optional `scope: "media" | + "tier-migration"` (readers ignore unknown keys; `cues.mjs`' twin too). +- `moveOut` preflight, after the writers check and before `measureTree`: `tierChannelMedia(paths, slug, + { createMediaDir: true })`; then today's phases with `dataDir`→`channelMediaLink`, parked names + `media.relocated-<ts>` / `media.incoming`, `live: channelMediaLink` for `assertMirrorDirection`, + `patchChannelConfig(…, { mediaDir })`. Preview tiers a classic channel the same way (same-fs, + idempotent) and reports "N files tiered first". +- `moveBack` identical, ending with `media/` a real SSD dir and `mediaDir` unset. Space check = `media/` + bytes + margin. `relocationRootProblem`/`assertRelocationRootPresent`/`rootOfRelocatedMediaDir`. +- `repointStorageLocation` (`storageLocations.ts:698-770`): link `media`, key `mediaDir`; + `channelsOnLocation` keys on `mediaDir`; `locationOfDataDir` keeps its name (pure prefix test). + `renameChannel.ts:125-180` and `deleteChannel` (`channels.ts:590` → `rm(config.mediaDir)`) follow. + +### 6. Surfaces +- Storage panel (`StorageStage.tsx`): two rows under one heading — `aria-label="media path"` (kept; + `target ?? "<channelDir>/media (in place)"`), new `"text path"` (always in place), `"text bytes"`, + `"media bytes"` (media tier only), `"free on media volume"` kept. Buttons keep their names (`Move + media`, `Preview`, `Resume move`, `Reconcile and resume`, `Clear marker`); the hold sentence gains + "its text stays readable". `MediaLocationBadge` keeps every label and adds `legacy: "Media layout + retired"` (danger). +- Snapshot (`channelSnapshot.ts:877-890`): `totalTextBytes`, `totalMediaBytes` (media tier only), + `totalClipsBytes` (no longer inside media); an old snapshot without `totalTextBytes` reads as unknown. + `/storage` (`buildStorage.ts`, `views/storage.ts`): location rows show media-tier bytes; the internal + row "text N GB + clips N GB on the corpus volume, plus media of in-place channels"; `legacy` counts as + unreachable with "(n to migrate)". Rack chip and `mediaHold` unchanged. +- umtool twin `umtool/report-to-video/cues.mjs:285-360`: refuse `legacy` (data link or `dataDir`); + refuse a marker only when `scope === "tier-migration"`. + +## The one-off migration — `common/bin/migrate-media-tier.ts` +Wired as `archilyzer storage migrate-tier <slug>|--all [--dry-run] [--reclaim] [--order smallest]` +(`archilyzer.ts:268` `script([...])` beside `migrate channel-priority`). Editor STOPPED (it cannot see +the in-process registry; refuses on a marker and says so). Dry run writes nothing; idempotent; +resumable from the marker's phase. Per channel with `config.dataDir = D` (`<root>/<slug>/data`): + +| phase | action | +|---|---| +| preflight | status must be `ok`; `assertRelocationRootPresent(root)`; walk `D` with `classifyVideoDir` → counts/bytes by kind; space: `textBytes + resumeMarginGB ≤ free(channelsDir) − 5 GB floor` | +| `copy` | marker `{target: <root>/<slug>/media, direction: "out", phase: "copy", scope: "tier-migration"}`; NUL list of text + scratch entries (classifier, not globs); `rsync -a --partial --info=progress2 --from0 --files-from=$T/<slug>.txt D/ channels/<slug>/data.incoming/`; verify `rsync -a --dry-run --itemize-changes --from0 --files-from=… D/ data.incoming/` empty AND per-kind counts/bytes equal; then for every `<id>` and every `isTierable` name: `mkdir data.incoming/<id>`, `symlink("../../media/<id>/<name>", …)` (EEXIST = already) | +| `swap` | platter: `rename(<root>/<slug>/data, <root>/<slug>/media)`; SSD: `symlink(<root>/<slug>/media, channels/<slug>/media)`; `unlink(channels/<slug>/data)`; `rename(data.incoming, data)`; `patchChannelConfig(slug, { mediaDir }, { unset: ["dataDir"] })` — each step observes the disk (`linkOrDirState`) first | +| `reclaim` | only `--reclaim`: delete from `<root>/<slug>/media/<id>/` every entry that is not `isTierable` (the platter's text copies and scratch), drop empty dirs; without it the duplicates stay until `migrate-tier --reclaim` | +| done | clear the marker; print bytes by kind and links made | + +No index rebuild: `rsync -a` keeps the text mtimes and the index stats only sidecars. The snapshot's new +byte fields come on the next Refresh report. Order `--order smallest`: realcandaceo (6 MB text), +nuxanor-kick, omnimirror, leaflit-rumble, cornbreadman, shondo-vods, piratesoftware, kirsche, +friday-night-tights, HasanAbiVODs3 (≈ 36 GB of SSD with live chat cold → ~16 GB; plus 15 GB clips for +nuxanor-kick) — then STOP and report free space before omnibased (15.2 GB), rekietalaw (8.9 GB with chat +cold), the-quartering-rumble (10.3 GB). Run `purge-superseded-auto-subs` on omnibased/HasanAbiVODs3 +first (≈ 7.6 GB of `en-orig.vtt`). Live proof: realcandaceo (seconds) → Refresh report, one video page, +one short Transcribe (the hook on a relocated channel), `/storage`, `df`; then nuxanor-kick; then `--all`. + +## umtool +- `umtool/lib/paths.mjs`: `MEDIA_ROOT = resolve(/* turbopackIgnore: true */ process.env.UMTOOL_MEDIA_DIR + ?? REPORTS_ROOT)`, `MEDIA_TIERED`, `mediaMirror(abs)` = the project-relative path under `MEDIA_ROOT`; + `MEDIA_ROOT` joins `READ_ROOTS` (never `WRITE_ROOTS`). `CACHE_DIR` decoupled from `SONG_DATA`: + `UMTOOL_CACHE_DIR ?? $XDG_CACHE_HOME/archilyzer/umtool`; the e2e env sets it to `${FIXTURE}/cache` + (`playwright.config.ts:62,73`, `projects.spec.ts:318`, `report-longform.spec.ts:21`, `make-fixture.mjs`); + rollout runs `umtool index` once; `umtool doctor` reports both roots and a leftover old cache. +- `umtool/lib/report/storage.mjs`: `ensureOutDir(projectDir)` (real dir → it; link → it; absent + + tiered → `mkdir(mediaMirror(projectDir)/out)` + `symlink`; absent + untiered → `mkdir`), + `moveDirToMedia(projectDir, name)` (rsync copy, mirror `--delete --info=del`, verify, `rename(name → + name.moved-<ts>)`, symlink, rm parked; idempotent) and `moveDirToLocal`. Called at `driver.mjs:74,87`, + `build-video.mjs:2615` (`outRoot` default), `export.mjs:71`. A dangling `out` link makes `mkdir -p + out/clips-raw` fail loudly (nothing materialised). tmp-then-rename sites (`cut.mjs:98-99`, clip route + `:52-55`) untouched: tmp and final share a directory either way. `umtool storage move-out + <project>|--all` moves existing `out/` trees (≈ 18 of the 20 GB in `~/reports`). +- Deliverables: `video.manifest.json` gains `"storage": { "deliverables": "local" | "media" }` (absent = + local), written only through `lib/report/manifest.mjs`'s writer; `umtool storage deliverables <project> + --to media|local` and a bench button "Move deliverables" move `clips/` and every `share-*/` with the + movers (only when nothing is cutting/sharing), then set the field; `cut.mjs:96` and `deliver.mjs:362` + create their dir through `deliverableDir(project, name)` (links when `media`); relative references + (`clips/<id>.mp4`) stay valid through the link. `kinds.mjs` `SKIP_DIRS` adds `clips`, plus a + `SKIP_PREFIXES = ["share-"]` so an unmounted media drive is never descended. Final mp4s travel with + `out/`. `umtool check` learns the two values. + +## Slices (record: this file; `editor/CHANGELOG.md` + `umtool`'s changelog `[Unreleased]`) + +| slice | branch | owns | after | gates beyond the common set | +|---|---|---|---|---| +| **D0** dashboard answers | `r17/dashboard-answers` | `common/controller/channelSnapshot.ts` (the yielding loop only — T1 owns its guard/bytes changes, so D0 lands first and T1 merges main), `common/jobs/snapshotScheduler.ts`, `common/views/autoQueueStatus.ts`, `common/jobs/registry.ts` (stale-meta finalisation), `editor/instrumentation.ts`, `common/controller/channelWriters.ts` (ignore interrupted), a new e2e spec | — | see Step 0b | +| **T1** classifier + model + guards | `r17/media-tier-model` | `common/lib/mediaTier.ts` + `mediaTier-server.ts` (+ tests), `channelConfig*.ts` + CHANNEL.md (`mediaDir`, retired `dataDir`), `channelMedia.ts` (`legacy`, `media` link, two guards), `channelMediaHold.ts`, `storageHealth.ts:529`, `jobKinds.ts` flips, `autoRunner.ts` lanes, the walkers' text guard (`buildIndex`, `buildStats`, `channelSnapshot` + byte fields, `operationBatch`, `normalizeAll`, `shardActions`), `channelWriters.ts` `mediaOnly`, the hook call sites (transcode, downloadOneManaged, runYtdlp, normalizeLiveChat, backfillReacquire), every deleter, `recencyIndex.ts`, `channels.ts`, `cues.mjs` twin | — | classifier table incl. `audio.en-orig.vtt`, `audio.tmp-2760235.mp3`, `source-media.temp.mp4`, `transcript.live_chat.json`; hook tier/already/left, EXDEV (injected `rename` throwing EXDEV), dangling `media` link creates nothing, non-recursive mkdir; deleters deref; `channelMedia.test.ts` + legacy cases; `jobKinds` flips pinned; "a stalled media location does not hold the index/stats/snapshot, holds the transcription lane, lets the digest lane run" | +| **T2** mover + surfaces | `r17/media-tier-mover` | `relocateChannelMedia.ts` (over `media/`, preflight tier), `relocateDir.ts` (names), `storageLocations.ts` (lib + controller: re-point, rollups), `renameChannel.ts`, `deleteChannel`, editor `StorageStage.tsx`, `storageActions.ts`, `relocationJob.ts`, `bulkStorageActions.ts`, `MediaLocationBadge.tsx`, `channelRow.ts`, `videos/[id]/page.tsx` + `videoActions.ts` (`loadVideoDir`, file-kind `onDrive`), the file route 503, `videos/page.tsx`, `[slug]/page.tsx`, `buildStorage.ts`, `views/storage.ts`, `storageWatch.ts` | T1 | `relocateChannelMedia.test.ts` rebased on `media/` (classic → tiered → moved; interrupt at each phase + resume; reconcile; back leaves a real `media/`); `renameChannel`, `storageLocations`, `channels` tests; e2e `channel-storage`, `storage-locations`, `channels-storage-columns`, `bulk-actions`, `maybe-missing`, `video-page`, `fetch-window` (+ the new cases under Verification) | +| **T3** migration + records | `r17/media-tier-migrate` | `common/bin/migrate-media-tier.ts`, `archilyzer.ts` wiring, fixture tests (tmp "platter"), FACTS "A channel's media is tiered", AGENTS.md's six things → seven, SETTINGS.md/CHANNEL.md regen, the release record, changelog | T1, T2 | dry run; resume from each phase; idempotent rerun; `--reclaim`; refusal on a marker; the free-space stop | +| **U1** umtool roots + `out/` | `r17/umtool-media-root` | `paths.mjs` (`MEDIA_ROOT`, `CACHE_DIR`), `lib/report/storage.mjs`, `driver.mjs`, `build-video.mjs:2615`, `export.mjs`, `kinds.mjs`, `umtool doctor`, `umtool storage move-out`, the e2e env | — (∥ T1) | `test:scripts` (+ mover tests), `next-build-trace.test.mjs`, the capped umtool build with the corpus linked, umtool e2e | +| **U2** deliverables switch | `r17/umtool-deliverables` | manifest `storage` field, `deliverableDir`, `cut.mjs:96`, `deliver.mjs:362`, `umtool storage deliverables`, bench "Move deliverables", `umtool check` | U1 | umtool unit + e2e: cut and share through a linked `clips/` | + +Order: 0a → D0 ∥ T1 ∥ U1 → T2 ∥ U2 → T3 → parent: records, ONE editor rebuild + restart, umtool rebuild + +restart (the restart is the operator's: the permission layer refuses the `0.0.0.0` bind) → the migration +(editor stopped for it) → live proof → records/STATE/memory. AGENTS.md's seventh non-optional thing: **"A +media file in `data/<id>/` may be a relative symlink into `channels/<slug>/media/`. Remove one with +`removeMediaFile`, never `rm`/`remove`; tier one with the hook after every media finalisation, never by +hand; a dirent `isFile()` filter over a video dir hides it."** The `.relocating.json` bullet gains +`scope`; the `clips/` bullet gains "on the SSD, never tiered". + +## Verification +- Unit as the table says; plus the health split end to end: a stalled/absent media location → index, + stats and snapshot run; digest lane runs; transcription lane skips; `whisper-video` refused; + `normalize-transcripts` runs; `normalize-live-chat` refused. +- e2e (`channel-storage.spec.ts` rebased): after Move media, `data/<id>/audio.mp3` is a link and + `transcript.live_chat.json` is a link; the videos list and the file route serve the transcript AND + stream `audio.mp3` through the link; a renamed-away media root (the `seedDriveChannel` + rename trick + from `buildStats.test.ts`) keeps the video page's text readable, the file route answers 503 for + `audio.mp3`, the rack shows "media held", the digest lane still picks the channel; Move back; a + `legacy` fixture shows "Media layout retired" and a media job's refusal names `migrate-tier`. + `migrate-tier` on a fixture: dry run, run, rerun, `--reclaim`, kill between `copy` and `swap`, resume. +- Live: realcandaceo → nuxanor-kick → `--all --order smallest` with the stop at the big three; after each + channel: Refresh report, one video page, one short Transcribe, `/storage`, `df`. + +## Assumptions the operator can overturn + +| assumed | alternative | +|---|---| +| A′: relative per-file links into `channels/<slug>/media/`, `media/` is the unit of relocation | absolute per-file links (no `media/` tree): a per-video mover for out and back, O(files) rewrites on re-point/rename | +| `dataDir` kept one release as `legacy` (held) | drop it now; the migration reads the raw file (`migrate-channel-priority.ts` pattern); risk: a stamp before migration erases the record | +| a move holds media writers only; digests run during a move | hold every writer as RM does today (no `channelWriters` change) | +| the `media` link is `channels/<slug>/media` (visible) | `.media` (hidden) | +| platter reclaim is opt-in (`--reclaim`) | reclaim in the same run after verify | +| the move's preview tiers a classic channel (same-fs renames) | preview only reports "N files to tier"; the job tiers | +| umtool `out/` is a directory link made by the first writer; existing `out/` stay until `move-out` | resolve `out/` through a function (no link): ~20 join sites + `deckPreviewFile`'s base change | +| auto-pause stays one tier per channel | a media stall pauses only the media lanes (per-lane `autoPaused`) | +| `UMTOOL_MEDIA_DIR` unset by default (behaviour unchanged until set) | default to `settings.storage.locations[platter].root + "/umtool"` (umtool reads no settings today) | + +## Open questions (settled during T1/U1; the answer is recorded in the slice's section) +1. What `import-one` writes (its controller was not found; `pipelineActions.ts:487` enqueues it) — assumed a media writer. +2. The four `*.mp4` at umtool project roots — deliverables or inputs? Left untouched. +3. `reconcileVideoDirs` on a tiered dir: links move with the files; `media/<extractorId>/` keeps its old name (valid, untidy) — a follow-up renames it. +4. Whether the Platter's media root should be created as `<root>/<slug>/media` beside today's `<root>/<slug>/data` (the migration renames one to the other) — yes as planned; confirm no other tool hardcodes `<root>/<slug>/data`. + +## Follow-ups carried over (not scheduled; keep) +- "Load more results" never resumes a leaf that settled at its cap (`runQueryTree`'s `setHitLimit` reaches running leaves only) — release 16 CK R-I1; wants a spec. +- `/changelog/` overflows a 390 px phone (long inline code in `export/CHANGELOG.md:103`); wrap `<code>` in the changelog renderer, then drop the `test.fail` in `export/e2e/responsive.spec.ts`. +- `export-search.spec` "Advanced reset does not touch filter checkboxes": the "Deleted" checkbox locator also matches a result card's "Select … for AI" box — `exact: true`. +- `social-channel.spec` fetch-posts case runs 22–26 s of a 30 s budget — `test.slow()` or a 60 s timeout. +- The JSX entity/whitespace hazard sweep: 22 texts in 19 files (FACTS). +- The editor's and export's build traces list dot-directories; `REQUIRED_TOKENS` lacks `--chart-other`; export `WorkspaceView` `splitOn` one-paint flash. +- The build-only "Build all" through the rebuilt build image when the machine is idle. +- Docker for others: no pull-able image; needs a GitHub home first; the version isn't tied to `release cut`. +- X section by-hand check (release 16 XL live). +- `channel-rename.spec`'s refresh race; RM L5: a cancelled job whose function never returns holds the channel's move until a restart (offer Force release on a cancelled row). +- XL L2/I-items: Firefox store discovery differs from gallery-dl's; the profile side's "logged in" check accepts any X domain. +- The `en` track → 0 cues bug (index prefers `en` over `en-orig`; some `en` VTTs parse to 0 cues). +- A channel export/import **bundle** built on `mediaTier.ts`'s classifier — the slice after this release. + +## Record + +## Rollout