# Relocate a channel's media to another drive **Ask (widened 2026-09-11):** hold large amounts of data on other hard drives, especially channels that are not being prioritized. The unit is a channel's downloaded media (`transcripts/channels//data/`), which moves to another directory or drive while every reader keeps working. **Box today (measured 2026-09-11):** `/home` (nvme, 1.5 T, holds `transcripts/`) is at **100 %, 6.9 G free**. `transcripts/` is **596.8 GB** — `channels/` 448.7 GB, `saved-videos/` 130.5 GB, `index.mdb` 13 GB. The platters `sda1` (466 G, ext4) and `sdb1` (1.8 T, ext4) are present and **still unmounted** (`lsblk`). Mounting one is a prerequisite of the rollout, not of the code. **Branch:** this work lands on a new branch **`storage/relocate-media` off `61eae05`** (`one-core/phase-1` is unmerged and this builds on it). **No slice moves any data.** The rollout is the operator's, through the UI, after the platter is mounted. --- ## What the tree says (re-verified 2026-09-11 at `61eae05`) The 2026-08-24 survey was verified at `46ade6d`. Everything below has been re-checked; every line number that moved is corrected here. | Fact | Anchor at `61eae05` | |---|---| | There is still **no** `channelDir()`/`videoDir()`/`dataDir()` helper. `"data"` is joined inline at **exactly 74 non-test call sites across 53 files** — 57 in `common/`, 14 in `editor/`, 3 in `umtool/`, 0 in `export/` and `mcp/`. Most sites have only `(paths, slug)` in scope, no config. | `runYtdlp.ts:563,1048,1331,1445`; `downloadOneManaged.ts:435,514,650,866`; `channelSnapshot.ts:602`; `channels.ts:177,196,251`; `buildIndex.ts:298,1111`; `autoRunner.ts:1422`; `operationBatch.ts:958,1580`; … | | yt-dlp output templates are still **relative** (`-o data/%(id)s/…`) and the download dir is set purely by `cwd`. No `-P`/`--paths`. | `runYtdlp.ts:256-262` (`OUTPUT_ARGS`), `:339` (`cwd: root`), `:1648` (`runChildAndStream(cwd)`); `downloadOneManaged.ts:307` | | `archive`, `playlist`, `config.json`, `snapshot.json`, `roster.json`, `failed-transcriptions` all live in the channel dir, **not** in `data/`. The archive is appended by the app. | `downloadOneManaged.ts:417` | | **Zero** symlink-aware code (`lstat`/`readlink`/`realpath`/`symlink`) in `common/`, `editor/` or `export/`. Re-confirmed at `61eae05`: the grep returns nothing. Every reader goes through `path.join(channelDir, "data", …)` and follows a symlink transparently. | grep | | Channel listing filters `isDirectory()` on `channelsDir` entries — a symlinked **channel dir** would vanish. Only `data/` may be a link. | `channels.ts:246`, `channels.ts:363` (`listChannelSlugs`), `buildIndex.ts:282`, `common/lib/site.ts:380` | | Snapshot swallows a missing `data/` as "no videos": `readdir(dataDir).catch(() => [])`. An unmounted drive reads as *everything undownloaded*. | `channelSnapshot.ts:602` (join), `:623` (the swallow) | | **`runOperationBatch` has the same swallow**, and it is new since the last survey: `readdir(dataDir).catch(() => [])` decides the candidate list for both operation lanes. | `operationBatch.ts:1578-1580` | | `deleteChannel` is `rm(dir, {recursive})` — on a symlinked `data/` that removes the **link**, orphaning the media. | `channels.ts:476-482` | | `renameChannel` renames the channel dir (a symlink inside moves fine) and already has an EXDEV message plus a rollback for the saved-video store. | `renameChannel.ts:80` (rename), `:92-95` (store move, rollback, EXDEV) | | Rename refuses while the channel has running/queued jobs — the guard to copy. **Moved** from `actions.ts:463-473`. | `editor/app/channels/actions.ts:541-552` | | LMDB index stores ids + mtimes, **no paths**. A move preserving mtimes (`rsync -a`) needs no reindex. | `buildIndex.ts:460-461` (`mtimes` sub-DB), `:566,796` | | Low-disk gate is still hard-wired to `paths.transcriptsDir` and the hysteresis latch is still **one global boolean**. `checkDiskSpaceFor(dir, settings)` and `getFreeBytes(dir)` exist and take a dir; `diskGate` does not. | `diskSpace.ts:13,37,57-61,181,207,225,238,244` | | `diskGate` callers (**all moved or new** since the last survey). | `runYtdlp.ts:876`; `autoRunner.ts:1059` (was `:654`); `backfillReacquire.ts:166` (was `:79`); `persistKept.ts:106`; `pipelineActions.ts:49`; `persistActions.ts:32`; `fixIncompleteTranscript.ts:78`; `videoActions.ts:266,301`; **new:** `editor/app/jobs/active/buildActiveJobs.ts:347` (observe). `downloadOneManaged.ts` is **not** a caller — the old table implied it was. | | `/cleanup` is now **entirely snapshot-driven** — zero disk reads — so it is a downstream consumer of the snapshot guard, not a site of its own. The old anchor `editor/app/cleanup/lib/loadCleanup.ts:31` no longer exists. | `editor/app/cleanup/lib/loadCleanup.ts:10-41` | | `export/` never reads `data/`; `buildIndex` reads captions/metadata only. | still true | | **Precedent for exactly this shape:** the saved-video store — per-channel override, resolver, rsync mirror job with `execa` + `cancelSignal`, real-rsync unit tests against `paths.rsyncBin`. | `channelConfig.ts:97-101`; `savedVideo.ts:52-67`; `savedVideo-server.ts:55-82`; `backupSavedVideos.ts:76-90`; `backupSavedVideos.test.ts:29-40`; `paths.ts:117,226` | | Job launch template: `runManagedFunction({kind, queueKey, paths, channelSlug?, videoId?, fn})`; a kind is one entry in `JOB_KINDS` with shape `{kind, label?, drainable, replayable, queueKeyStrategy, defaultTier?}`. 40 kinds today. | `common/jobs/streamCommand.ts:264`, `:97-104` (opts), `:110-127` (`makeJob`); `common/jobs/jobKinds.ts:22-40` | | **`architecture.test.ts` forbids `lib -> controller`, `lib -> jobs`, `jobs -> controller`, `components -> {controller,jobs,ytdlp}`. The allow-list can only SHRINK — a new entry fails the build.** | `common/architecture.test.ts:31-35` (`FORBIDDEN`), `:24-26` (the rule) | | `common/package.json` exports `"./lib/*": "./lib/*.ts"` and `"./controller/*"` by wildcard — **a new module needs no exports-map edit.** | `common/package.json:34-36` | | `ChannelForm.tsx`'s "Saved-video store dir" field is **unmoved**. | `editor/app/channels/components/ChannelForm.tsx:641-647` | | The channel page renders stage components under `[slug]/components/stages/` with `[slug]/components/flow/` for the overview; `ChannelFormClient`, `RenameChannelForm`, `DeleteChannelForm` are composed in `page.tsx`. The old flat `components/` layout is gone. | `editor/app/channels/[slug]/page.tsx:53-67` | --- ## Decision: symlink is the mechanism, `config.json` is the record, a guard makes it safe **Unchanged, and not reopened.** **A. Resolver migration** (a `channelDataDir()` helper plus a rewrite of all 74 call sites and absolute `-o` for yt-dlp) is correct in principle, but most sites don't have the config in hand, the runners touch production data unattended, and **one missed site silently reads an empty dir** — the auto-runner would then "re-download" 130 GB onto a full SSD. **B. Symlink** — the job copies `data/` out, verifies, swaps in `channels//data -> //data` and records the target in `config.json` — keeps every reader, yt-dlp's `cwd`-relative writes, the LMDB index and the export build working with **no call-site changes**, because the on-disk contract (`channelDir/data//…`) is preserved. **Go with B.** One-core **Phase 2's `ArchiveReader`** (`plans/one-core.md` §Phase 2) is where a resolver would later live, and `config.dataDir` — the field designed here — is exactly the field it would read. Option A is a layer on top of B, not a replacement for it. ### Layout - Target: `//data/` — `` is any directory the operator picks (e.g. `/mnt/platter/archilyzer-media`); the `/data` suffix is fixed so one root can hold many channels and the shape mirrors the saved-video store. - `channels//data` becomes an **absolute** symlink to that target. - `config.json` gains `dataDir: "//data"` (the link target, stored resolved). Blank/absent = in place. Written **only by the relocate job on success** — never a free-text field that can drift from disk. - Marker while a move is in flight: `channels//.relocating.json` `{ target, direction: "out"|"back", startedAt, phase }`. Removed on completion. Its presence means "media is in transition" to every guard below, and lets an interrupted job resume. --- ## Changes ### 1. `common/lib/channelMedia.ts` (new) — the one place that knows about relocation ```ts export type ChannelMediaLocation = { dataDir: string; // always channelDir/data — the path readers use relocated: boolean; // config.dataDir set target?: string; // config.dataDir status: "in-place" | "ok" | "unreachable" | "in-transition" | "inconsistent"; detail?: string; // "target /mnt/… does not exist (drive not mounted?)", … }; export async function inspectChannelMedia(paths, slug, config?): Promise export async function assertChannelMediaReachable(paths, slug, config?): Promise export function relocatedDataDir(root: string, slug: string): string // //data export class ChannelMediaUnreachableError extends Error {} ``` `inspect` = `lstat(data)` (is it a link? does that agree with `config.dataDir`?) + `stat(target)` (exists, is a directory) + `.relocating.json` presence. Because the link points at a *deep* path, an unmounted root gives `ENOENT` — an empty mountpoint is not mistaken for the media. Two stats; render-safe. **It lives in `lib/` and so may not import `controller/` (`architecture.test.ts:31-35`), which is where `readChannelConfig` is (`channels.ts:148`).** The `config` argument is therefore optional, and when it is absent `channelMedia.ts` reads `/config.json` itself and pulls only `dataDir` out of it — a four-line JSON read, no controller import, no allow-list entry. Callers that already hold a config pass it and pay nothing. **Do not add an entry to `ALLOWED` to get green; the test fails on additions by design.** Unit-tested with tmp dirs. ### 2. Guards — the post-Phase-1 dispatch makes this four sites, not a file list The 2026-08-24 plan listed six action files to patch individually. Phase 1 changed the dispatch, and the enumeration below is from reading the code, not from assumption. **What reaches a channel's media, and through what:** - **`runManagedFunction` (`common/jobs/streamCommand.ts:264`) is the funnel for every job-shaped action.** 53 call sites; **35 already pass `channelSlug`** — `whisperActions` 10, `videoActions` 6, `availabilityActions` 3, `pipelineActions`/`socialActions`/ `incompleteTranscriptActions`/`operationJobs`/`workerServer` 2 each, `digestActions`/ `normalizeActions`/`persistActions`/`channels/actions.ts`/`snapshotScheduler`/`autoRunner` (the download unit) 1 each. The 18 without a slug are the lane runners, `/review`, saved-video backup and the site build/deploy — none of them per-channel media work. - **`runOperationBatch` (`operationBatch.ts:1544`) is per-channel** and swallows a missing dir at `:1580`. Callers: `operationJobs.ts:103,236` and `digestActions.ts:118` — all three already inside a `runManagedFunction`. - **`runOperationUnit` (`operationBatch.ts:952`)** is reached from `runOperationBatch:1770` **and directly from `autoRunner.ts:1262`** — so the digest and backfill **lane runners do not go through `runOperationBatch`**. That is the one path a batch-level guard misses. - **The four lane runners** are one cross-channel job each (`autoRunner.ts:1597`, `queueKey: ""`, **no `channelSlug`**). They enumerate at `listChannelMeta` (`:288`), project work at `buildChannelWork` (`:299`), and launch transcription/download units at `launchUnit` (`:1431`). - **`bulkVideoActions.ts` bypasses nothing** — all ten of its actions delegate to `whisperActions` / `pipelineActions` / `incompleteTranscriptActions` / `videoActions` (`:12-23`), every one a `runManagedFunction` site. **`shardActions.ts:87` is the one true bypass**: it calls `runYtdlp` directly, with no job record. **So: four guard sites.** 1. **`common/jobs/streamCommand.ts:264`** — in `runManagedFunction`, when `opts.channelSlug` is set **and** the kind declares it touches media, `assertChannelMediaReachable` before the job record is made; on failure return `{ ok: false, error }` without enqueuing. The declaration is a new **`needsMedia?: boolean`** on `JobKindMeta` (`jobKinds.ts:22-40`) so a bookkeeping kind (normalize, availability check, store playlist, clear markers) is not refused for a drive it never reads. `jobs/` may import `lib/` — no back-edge. 2. **`common/controller/autoRunner.ts:299`** — in `buildChannelWork`, skip a channel whose media is not `ok`, for all four lanes at once, with a **once-per-state-change** log line. This is the runners' only channel-level chokepoint and it covers the direct `runOperationUnit` call at `:1262`. **A skip is a skip: the lane keeps running other channels. It is never a lane stop, and it is not a hold** — "a zero limit is a hold, never a stop" (`pauseGates.ts`) is untouched by this and must stay untouched. 3. **`common/controller/channelSnapshot.ts:623`** — before the `readdir`, throw `ChannelMediaUnreachableError` rather than computing a snapshot over an empty dir. The scheduler already keeps the last `snapshot.json` on a failed refresh (`snapshotScheduler.ts:205-212`); verify that path logs the reason rather than swallowing it. Everything downstream of the snapshot — `/cleanup`, the channel bands, the lanes' work lists — is protected by this one guard. 4. **`common/controller/operationBatch.ts:1578`** — assert before the `readdir`. Defence in depth today (all three callers are already behind guard 1), correctness tomorrow for any in-process caller that is not. Plus the one bypass: **`editor/app/channels/[slug]/shardActions.ts:87`** gets an explicit `assertChannelMediaReachable` at the top of the `download-missing` branch. **Surfacing:** channel page and `/channels` render a badge from `inspect()` — "Media relocated to `` · reachable" / "**unreachable** — mount the drive". ### 3. `common/controller/relocateChannelMedia.ts` (new) — the move, both directions `relocateChannelMedia({ paths, slug, direction: "out", root, onLog, signal })`: 1. **Preflight** (all throw operator-readable messages): channel exists and is not social; no `.relocating.json` for a *different* target; `root` exists, is a directory, is writable; target `//data` is absent **or** a resumable partial; free space on `root` ≥ bytes in `data/` + `resumeMarginGB` (`getFreeBytes(root)`, `diskSpace.ts:13`); source `data/` is a real directory, not already a link. 2. **Write the marker**, phase `copy`. 3. **Copy**: `rsync -a --partial --info=progress2 / /` via `execa(paths.rsyncBin, …, { cancelSignal: signal })`, exactly as `backupSavedVideos.ts:76-90`. `-a` preserves mtimes → no LMDB reindex. An abort leaves the source untouched and the target resumable. 4. **Verify**: `rsync -a --dry-run --itemize-changes` must print nothing; then file count and byte sum must match both sides. Any drift → fail, source untouched, marker stays. 5. **Swap** (phase `swap`): `rename(data, data.relocated-)` (same device, atomic) → `symlink(target, data)` → `writeChannelConfig({ ...config, dataDir: target })` (`channels.ts:452`). 6. **Reclaim** (phase `reclaim`): `rm -r data.relocated-`; remove marker; log bytes moved and free space on the source volume now. `direction: "back"`: copy `target/` → `channels//data.incoming/`, verify, `unlink(data)`, `rename(data.incoming, data)`, clear `dataDir`, `rm -r target`, remove marker. **Crash recovery** is the marker's `phase`: a rerun with a matching marker re-verifies and continues from `swap`/`reclaim`; `inspect()` reports `in-transition` until it finishes. ### 4. Job kind + actions - `common/jobs/jobKinds.ts`: `"relocate-channel-media"` — label "Relocate channel media", `drainable: false`, `replayable: false`, `queueKeyStrategy: "custom"`, `needsMedia: false` (it *is* the thing that fixes an unreachable channel; guard 1 must not refuse it). Queue key = `channelQueueKey(slug)` (`queueKeys.ts:34`) so it serializes with the channel's own jobs. - `editor/app/channels/[slug]/storageActions.ts` (new, `"use server"`): - `previewRelocationAction(slug, root)` → `{ bytesToMove, freeOnRoot, freeOnSource, sameDevice, existingPartial, error? }`. - `relocateChannelMediaAction(slug, root)` / `moveChannelMediaBackAction(slug)` — the rename-style active-jobs guard (copy `actions.ts:541-552`), then `runManagedFunction`. - `deleteChannel` (`channels.ts:476`): if `config.dataDir` is set, `rm -r` the target first (and `/` if then empty), then the channel dir. Refuse when a `.relocating.json` marker is present. - `renameChannel.ts`: after the channel-dir rename (`:80`), if `dataDir` matches `//data`, rename `/` → `/`, re-create the symlink, rewrite `dataDir`; roll back the dir rename on failure (same shape as `:92-95`). A target not following the convention is left alone — the link is absolute and still works. ### 5. Disk gate measures the right volume `diskGate(paths, settings, { mode, dir? })` — `dir` defaults to `paths.transcriptsDir`. Thread the channel's `data` path from the callers that write media for a known channel: `runYtdlp.ts:876`, `autoRunner.ts:1059`, `backfillReacquire.ts:166`, `pipelineActions.ts:49`, `fixIncompleteTranscript.ts:78`, `videoActions.ts:266,301`. The global latch (`diskSpace.ts:181` `gateLatched`, read at `:230,238`, cleared at `:244`) becomes a `Map` so a full SSD does not pause downloads landing on the platter, and vice versa. `persistKept.ts:106` and `buildActiveJobs.ts:347` (observe) keep their current dir. `diskSpace.test.ts` extends the hysteresis cases to two dirs. ### 6. UI A "Storage" stage alongside the existing ones (`[slug]/components/stages/`, composed in `page.tsx:53-67`): location, status from `inspect()`, media bytes (from the loaded snapshot — no new walk), free space on the current volume, a "Move media to…" form (root input prefilled from the settings default, live preview, confirm) and "Move back in place" when relocated — disabled with a reason while the channel has jobs or a marker is present. In `ChannelForm.tsx:641-647`, next to "Saved-video store dir", a **read-only** line "Media location: `` — change from the Storage panel"; not an input. A small "relocated" badge on the channel page and in `ChannelsTable.tsx`, red when unreachable. ### 7. Docs - `AGENTS.md` corpus table: `data/` may be a symlink to `config.dataDir`; never symlink a whole channel dir (`isDirectory()` filters at `channels.ts:246,363`); the marker file name. - `RUNNING_IN_DOCKER.md`: the corpus is a named volume, so a relocated target must be bind-mounted **at the same absolute path** — otherwise the guard reports the channel unreachable, by design, rather than falling back to empty. - `WORKTREES.md`: `rsync -a` of `channels/` copies the link as a link; use `--copy-links` when a shard must carry the media. `editor/CHANGELOG.md` gets an entry. ### 8. Cold root (settings default) + bulk move — the widened ask Minimal, one field and one action. **No tiers, and no coupling to auto-queue weights** — a relocated channel is not thereby deprioritized, and a deprioritized channel is not thereby relocated. Coupling the two ("auto-relocate anything below weight N") is a possible later step and is deliberately not designed here. - **`storage.mediaRoot`** on `SiteSettings` (`common/lib/settings.ts:82`), with a `sanitizeStorage()` beside the existing sanitizers (`:735-1135`): trimmed string, blank = none, no validation of existence at sanitize time (the drive may be unmounted when settings are read). Rendered on the settings page (`editor/app/settings/components/SettingsForm.tsx`) as "Default media root", with the same "blank uses none" hint shape as `savedVideosDir`. It **prefills** the per-channel Storage panel's root input and nothing more — it is never read by the relocate controller, which always takes an explicit `root`. - **Bulk "Move media to " on `/channels`.** `ChannelsTable.tsx` has no per-row selection today (its only checkbox, `:224-232`, is the "Group by section" toggle), so the slice adds one: a per-row checkbox plus a selection bar, modelled on the video-list selection bar. The action (`editor/app/channels/bulkStorageActions.ts`) **enqueues one `relocate-channel-media` job per selected channel** on that channel's `channelQueueKey` — the job queue serializes them, there is no batch controller. Each job runs its own preview and space check **at run time**, not at enqueue time, so a root that fills partway through refuses the remainder cleanly. A channel already relocated to that root is skipped, not failed. The action returns a summary (`{ enqueued, skipped, errors }`) rendered in the selection bar. --- ## Tests - **Unit (`common/`, `node:test` + tsx, real rsync like `backupSavedVideos.test.ts:29-40`)**: `lib/channelMedia.test.ts` (in-place / ok / dangling target / marker present / link disagrees with config / config read without a passed config object); `controller/relocateChannelMedia.test.ts` (out: link + config + mtimes preserved + source gone; abort mid-copy leaves source intact and target resumable; rerun completes; verify-failure keeps source; back: restores a real dir and clears config); `controller/channels.test.ts` (`deleteChannel` removes the target and refuses under a marker; `renameChannel` re-points a convention-shaped target and rolls back on failure); `controller/channelSnapshot.test.ts` (throws on unreachable rather than writing an empty snapshot); `lib/diskSpace.test.ts` (per-dir latch, two dirs, existing hysteresis cases unchanged); `architecture.test.ts` must stay green **with no new `ALLOWED` entry**. - **e2e**: `editor/e2e/channel-storage.spec.ts` (slices 2 and 3). From a **worktree**, with a composed fixture site copied into `export/public` so the export webServer answers 200 (`plans/STATE.md`); the primary checkout is blocked by the untracked `editor/content` symlink. Runs behind the machine-global queue lock — **run detached**, ~25-32 min for the full suite. - `pnpm exec tsc --noEmit` in `common/` and in `editor/`; `pnpm --filter yt-dlp-transcript-common test`. - **Offline dry-run on the real corpus** (never a second editor — `instrumentation.ts` arms the runners): a `tsx` script calling `inspectChannelMedia` / the preview for `omnimirror` against a mounted root. --- ## Slices Three mergeable units on `storage/relocate-media`, each with its own gate. ### Slice 1 — core (no UI, no job) **Files:** `common/lib/channelMedia.ts` (new), `common/controller/relocateChannelMedia.ts` (new), `common/jobs/streamCommand.ts`, `common/jobs/jobKinds.ts` (`needsMedia`), `common/controller/autoRunner.ts`, `common/controller/channelSnapshot.ts`, `common/controller/operationBatch.ts`, `common/controller/channels.ts` (`deleteChannel`), `common/controller/renameChannel.ts`, `common/lib/diskSpace.ts` + its callers, and the six unit test files. **Commits:** (1) `channelMedia.ts` + its test; (2) the four guards + `needsMedia`; (3) `relocateChannelMedia.ts` + its test; (4) `deleteChannel`/`renameChannel` + tests; (5) per-dir disk gate + test. **Gate:** `pnpm --filter yt-dlp-transcript-common exec tsc --noEmit` · `pnpm --filter yt-dlp-transcript-common test` (includes `architecture.test.ts`, which must be green with no added `ALLOWED` entry) · `pnpm --filter yt-dlp-transcript-editor exec tsc --noEmit`. ### Slice 2 — editor: the job, the panel, the docs **Files:** `editor/app/channels/[slug]/storageActions.ts` (new), a `StorageStage` under `[slug]/components/stages/` wired in `[slug]/page.tsx`, `ChannelForm.tsx:641-647`, `ChannelsTable.tsx` (badge), `editor/app/channels/[slug]/shardActions.ts` (the one bypass), `AGENTS.md`, `RUNNING_IN_DOCKER.md`, `WORKTREES.md`, `editor/CHANGELOG.md`, `editor/e2e/channel-storage.spec.ts`. **Commits:** (1) job kind wiring + `storageActions.ts`; (2) the Storage panel + badge + ChannelForm line; (3) the shardActions guard; (4) docs + changelog; (5) the e2e spec. **Gate:** both `tsc --noEmit` · the common suite · `pnpm e2e -- channel-storage.spec.ts` from a worktree, detached. Cases: relocate the fixture channel to a tmp root from the panel, wait for the job, assert the badge, assert the videos list still lists videos and a transcript file route still serves, move back. ### Slice 3 — cold root + bulk move **Files:** `common/lib/settings.ts` (`storage.mediaRoot` + `sanitizeStorage`), `editor/app/settings/components/SettingsForm.tsx` + its action, `editor/app/channels/components/ChannelsTable.tsx` (per-row selection + bar), `editor/app/channels/bulkStorageActions.ts` (new), one more e2e case. **Commits:** (1) the settings field + sanitizer + form; (2) selection in `ChannelsTable`; (3) `bulkStorageActions.ts`; (4) the e2e case. **Gate:** both `tsc --noEmit` · the common suite (new `sanitizeStorage` cases in `settings.test.ts`) · `pnpm e2e -- channel-storage.spec.ts` from a worktree, detached. The added case: set the default root, select two fixture channels, Move, assert two jobs enqueued on two queue keys and both badges flip. --- ## Rollout — the operator's, after the platter is mounted `/home` is at **100 %, 6.9 G free**. Nothing below needs free space on `/home`: every copy goes **out** to the platter, and it is the swap that frees the source. The job path writes essentially nothing to `/home` during a copy — the job log under `transcripts/jobs/` and the `.relocating.json` marker, both kilobytes. The one real risk of a 100 %-full source is that an *unrelated* writer (a download, the LMDB index) fails mid-run; that is a reason to do step 0 first, not a reason to change the design. ### Step 0 — the saved-video store, by hand, today (no code, frees ~130 GB) — **SUPERSEDED** > **Superseded 2026-09-20 — see [`storage-locations.md`](storage-locations.md) S6.** The > hand-rolled rsync-and-symlink below is no longer the way: the saved-video store is movable > from `/storage`. `common/controller/relocateDir.ts` is the channel mover's factored core, > `relocateSavedVideos` is the store's mover, `settings.storage.savedVideosLocationId` > (`common/lib/settings.ts:965-974`, `common/lib/storageLocations.ts:82`) records where it > lives, and `transcripts/.relocating-saved-videos.json` is its in-flight marker. The move is > guarded by `assertRelocationRootPresent` > (`common/controller/relocateSavedVideos.ts:366,580`), which the by-hand runbook has no > equivalent of. Kept below because the reasoning about absolute `SavedVideoPointer.dir` > values is still exactly why the mover rewrites pointers rather than re-pointing a root. `transcripts/saved-videos` is **130.5 GB**, all of it one channel — `nuxanor-kick`, 29 containers, still being written to. **`SavedVideoPointer.dir` is an ABSOLUTE store dir** (`common/lib/savedVideo.ts:30-44`; a real pointer under `channels/nuxanor-kick/data//saved-video.json` carries `/home/user/Projects/yt-dlp-transcript-browser/transcripts/saved-videos//`), and `savedVideoPath()` (`:71-72`) joins `pointer.dir` with `pointer.file`. **So re-pointing `SAVED_VIDEOS_DIR` or a channel's `savedVideosDir` does NOT move the store** — `savedVideoDir()` (`:61-67`) recomputes from the root only for *new* persists and for `rewriteSavedVideoDir` (`savedVideo-server.ts:55-61`, which the rename uses). It would split future persists from the 29 existing pointers, which keep resolving to the old path. A **whole-directory symlink** keeps every absolute pointer valid with zero code — the same mechanism as the channel design. **Nothing walks the store root**: `resolveSavedVideo`, `unpersistSavedVideo`, `dropSavedVideo` and `pruneEmptyStoreDirs` are all driven off `pointer.dir` (`savedVideo-server.ts:86,146,157,163,168`); `pruneSavedVideos.ts:51` and `savedVideoInventory.ts:29,57` walk `channelsDir` and the per-channel `data/` dirs reading pointers, never the store; `backupSavedVideos.ts:154-166` walks the **destination** root, not the store. And there is still **zero** `lstat`/`readlink`/`realpath`/`symlink` anywhere in `common/`, `editor/` or `export/` — re-confirmed at `61eae05`, saved-video paths included. A symlinked store root is transparent to all of it. Runbook (the platter takes the copy, so this works from a 100 %-full `/home`): 1. Stop the editor — the persist job writes into the store. 2. `rsync -a --partial --info=progress2 transcripts/saved-videos/ /mnt/platter/archilyzer-saved-videos/` 3. `rsync -a --dry-run --itemize-changes transcripts/saved-videos/ /mnt/platter/archilyzer-saved-videos/` must print nothing. 4. `mv transcripts/saved-videos transcripts/saved-videos.moved-2026-09-11` 5. `ln -s /mnt/platter/archilyzer-saved-videos transcripts/saved-videos` 6. Restart; confirm one container plays and the persisted-video panel reads its size. 7. `rm -r transcripts/saved-videos.moved-2026-09-11` — **this is the step that frees the 130 GB.** Do this before any slice ships. It is why it is step 0. ### Step 1 — mount the platter — **SUPERSEDED** > **Superseded 2026-09-20 — see [`storage-locations.md`](storage-locations.md) S6.** The > platter is mounted and registered as the location `platter`, and its root is **not** > `/mnt/platter`: it is `/run/media/user//archilyzer-media`, a SUBDIRECTORY of the > mountpoint (FACTS, "`volumeFreeBytes` tests the mount boundary AT THE MOUNTPOINT" ~:4848 — > that subdirectory shape is precisely what broke the first free-space check). A root is > `join(mountpoint, relPath)`, the mountpoint and its uuid are learned by the probe, and a > location is re-pointed on `/storage` without moving a byte. Do not add `/mnt/platter` to > fstab. Mount `sdb1` (1.8 T ext4) at `/mnt/platter` with `nofail` in fstab; create `/mnt/platter/archilyzer-media` and `/mnt/platter/archilyzer-saved-videos`, owned by `user`. ### Step 2 — channels, largest first, through the UI Per-channel `data/` sizes, measured 2026-09-11 (`channels/` totals 448.7 GB across 68 channels; the top ten are 302 GB of it): | Channel | GB | |---|---:| | `omnimirror` | 130.3 | | `shondo-vods` | 42.4 | | `rekietalaw` | 29.6 | | `angryjoeshow` | 22.6 | | `cornbreadman` | 21.6 | | `HasanAbiVODs3` | 17.4 | | `omnibased` | 17.0 | | `nuxanor-kick` | 16.9 | | `chibi-reviews` | 13.1 | | `kirsche` | 10.8 | (Next: `the-quartering-rumble` 10.7.) **These are sizes, not priorities — the operator picks the order**, largest deprioritized channel first. `omnimirror` alone is 130 GB and is the obvious first move; it is also mostly-redundant mirror content, which is what makes it the cheapest thing to put on a slow drive. For each: Editor → channel → Storage → Move media to `/mnt/platter/archilyzer-media` (or select several on `/channels` and use the bulk action, once slice 3 ships). The preview shows the bytes; the copy is one platter write. The channel's own jobs are blocked for the duration; everything else keeps running. Afterwards the channel's next sync downloads straight onto the platter through the link, and the disk gate watches **that** volume for it. --- ## Out of scope (deliberately) - Per-video or per-file relocation (the saved-video store already covers "keep the big container elsewhere"); a named global list of storage roots beyond the one default; making shards symlink-aware; network stores. - Any coupling between relocation and auto-queue weights or lane priorities. Noted in §8 as a possible later step; not designed, not built. - Moving `index.mdb` (13 GB) — it is hot and small relative to the media. --- ## As shipped (2026-09-11) Branch `storage/relocate-media`, **`4059dad` → `affe525`** — 24 commits off `61eae05` to that code tip (the plans commit that opened the branch, 19 of code and tests, the suite fix, the records, and the two merge-review fixes), plus this record. **Unmerged.** All three slices landed on the day they were planned. **No data moved**: every byte in `transcripts/` is where it was, and the rollout below is still the operator's. | Slice | Commits | What landed | |---|---|---| | 1 — core | `1cf4e64` `5b0b18f` `a371832` `cb616e7` `c26b1d5` | `common/lib/channelMedia.ts`, the four guards + `needsMedia`, `common/controller/relocateChannelMedia.ts`, delete/rename reaching the other drive, the per-dir disk gate | | 2 — job + UI | `2cf7e37` `edbe6bd` `520a683` `a5dae31` `4c1b849` `1c5ef7b` `0d185a8` `0111a86` | the `relocate-channel-media` kind + `storageActions.ts`, the Storage panel and the badges, the ancestor walk, resume-observes-the-disk, two more `data/` readers, the shardActions bypass, the three docs, `editor/e2e/channel-storage.spec.ts` | | 3 — cold root + bulk | `bb92c00` `4039355` `00c1116` `45172e4` `96d2e25` `22a3b35` | `storage.mediaRoot` + `sanitizeStorage`, the two review rounds, per-row selection on `/channels`, `bulkStorageActions.ts`, the bulk e2e case | ### The three review rounds, and what closed them **Round one (`4039355`) — the two ways a move ended by deleting the only copy.** Both were reachable from the shipped panel and both ended in `rm -r` on media nothing else held. *Move back had no state precondition*: `moveOut` refuses unless the channel is in-place, `moveBack` never asked, and `inspect()` reports `relocated: true` for `inconsistent` and `unreachable` as well as `ok` — so the panel offered "Move back in place" for a channel whose config records a target while `data/` is a real directory, which is exactly what `rsync --copy-links` of a channel produces. Closed by the mirror refusal in `moveBack` plus a disabled button with the reason. *Nothing checked where the root was*: `root = /channels` makes `relocatedDataDir(root, slug)` the SOURCE, `rsync -a src/ src/` succeeds, `verifyCopy` compares the tree with itself, and the reclaim sweep then deletes the only copy — every check in the happy path being the tree against itself. Closed by `relocationRootProblem`, asked by all three callers (the preview the operator reads, the action that enqueues, the job that moves), comparing REAL paths and resolving the target separately from the root. The unit harness had nested its "platter" inside the corpus, which is the shape now refused, and is why 16 green tests never saw it. **Round two (`00c1116`) — a rollback that undid a step it had not recorded, and four smaller edges.** `renameChannel` recorded `movedMedia` and `relinked` but not the `unlink` of `data/` between them, so a throw from the `symlink` rolled the media directory back while `data/` stayed deleted — `inconsistent`, which round one had just made a state move-back refuses, so the rollback left a channel with no way back through the UI. Also closed: the missing resume cell (back @ copy — the only resume that finishes an rsync rather than a rename, and the only one asked to credit a partial copy); a preview that never checked the root existed (the ancestor walk statfs'd the parent of a typo and previewed plausible numbers the job then refused); move back's space check using neither the resume margin nor credit for the bytes already in `data.incoming`; and three nits (`moveBack`'s swap refusing a `data/` of kind "other", `sweepParked`'s unused `keep`, and the channel page reading the marker twice while telling the operator to delete it by hand above the button that does it). ### Round three (`7722acf`, `affe525`) — the merge review Both round-one blockers re-verified closed; two should-fixes landed before this became the merge candidate. **Ten ticked rows started ten rsyncs onto one drive.** The queue key is the only thing that decides whether two moves run at once — `registry.ts` submits every non-empty key at concurrency 1 and caps NOTHING across keys — so `channelQueueKey(slug)` serialized a channel against its own downloads and against no other channel. A bulk move therefore started one rsync per selected channel, simultaneously, all writing to one destination volume: the worst access pattern a platter has, and a broken space check, because each job runs its preflight when it STARTS and jobs that start together are each credited room the others have already claimed. Recoverable (ENOSPC aborts the copy, the source is untouched until a verify passes) but hours of copying to learn what one queue slot knew. Both comments asserted the opposite, and the bulk bar's no-preview-gate argument rests entirely on it. `relocationQueueKey()` takes no slug and lives in `common/lib/queueKeys.ts` beside `BACKFILL_QUEUE`; the one enqueue both actions share sets it, so there is no per-caller key to drift. Serializing against the channel's own jobs is not lost — both actions refuse a channel with running or queued jobs before they enqueue, which refuses rather than waits. `queueKeys.test.ts` states the claim as a function of the slug on both sides so it still reads as the claim (and fails) if someone gives the function a slug parameter; the e2e case asserts it where an operator sees it, two rows in the `/jobs` table both on `relocate` and neither on `channel:`. **And a channel with nothing to move is a skip, not a job**: no `data/` is `in-place` to `inspect()` — correctly — so the bulk path used to queue a job whose only act was to throw from the copy phase, which on a fresh corpus is most of a page. **A spec's settings are the fixture's plus what it names.** The suite fix below defaulted one key; that was the symptom. `writeSettings` REPLACED `test-settings.json`, so every key in `fixtures/test-settings.default.json` fell back not to something neutral but to the PRODUCT defaults — a different fixture, chosen for operators. Four diverge: `minFreeDiskGB` 0 vs 5 GB, `sleepBetweenDownloadsSeconds` 0 vs 10 s (ten seconds between every download in a fixture batch), `verifyAvailabilityBeforeClean` false vs true, `syncScheduler.fullSweepIntervalMinutes` 0 vs 1440. Eighteen spec files write settings without naming the floor, fourteen without naming the sleep. It merges now, one level deep for nested blocks, arrays and every other object replaced rather than merged (`workers: []` has to mean no workers), explicit winning at every level. The two other keys the fixture sets are inert: no spec asserts the admin title, and 8388608 IS `TRANSCRIPT_PAGE_DEFAULT_BYTES`. ### Merging with `channel-priority/s5` One semantic conflict, and it is in the file both branches changed for the same reason. `autoRunner.ts`'s `listChannelMeta` returns `{ meta, slugs }` on s5, with the paused channels filtered out of `meta` and every slug kept in `slugs` — and it DROPS `config` from `ChannelMeta`. This branch adds `config` precisely so the media guard can pass it: `inspectChannelMedia(paths, m.slug, m.config)`, which is what keeps the guard from re-reading 68 `config.json` files on every tick of four lanes and every three-second status poll. **Resolution: keep s5's return shape and its paused filter, AND keep `config` on each meta entry plus the media skip in `buildChannelWork`.** The two are orthogonal — one decides which channels are eligible, the other which of those can be reached. Everything else is keep-both: `ChannelsTable.tsx` (four hunks), `channels/page.tsx` (two), `CHANGELOG.md`, `STATE.md`, `FACTS.md`. `jobKinds.ts` does not conflict. The other files the two branches share — `backfillReacquire.ts`, `channelConfig.ts`, `settings.ts`, `channels/actions.ts`, `settings/actions.ts` — touch disjoint hunks. ### Divergences from the plan - **`needsMedia` is opt-in and absent means false** (`common/jobs/jobKinds.ts:457-462`). The plan described the guard, not the default. An unlisted or unknown kind behaves exactly as it did before the guard existed, which is what keeps `refresh-report` — not in the table at all — running and reporting its own refusal. `relocate-channel-media` writes `needsMedia: false` out rather than omitting it: it is the thing that FIXES an unreachable channel, so a `true` there would refuse it precisely when the operator needs it. - **`relocate-channel-media` joins `NO_REGEN_KINDS`** (`common/jobs/snapshotScheduler.ts:50`), which the plan did not call for. A move preserves every byte and mtime (`rsync -a`, the same property that makes the LMDB index a no-op), so a regen would walk 11,000 video dirs to write a byte-identical snapshot with a newer `generatedAt` immediately after moving 130 GB. It also removes a race the operator would feel: the Storage panel refuses a move while the channel has a queued job, so "move out, then move back" would be blocked by a report nobody needed. - **Preview is the confirm.** The plan said "live preview, confirm" as two things. The panel makes them one: Move is disabled until the root in the box is the root a preview described (`StorageStage.tsx:193-194`), and editing the input un-confirms it. There is no second dialog, and there is no way to move to a root whose numbers the operator has not seen. - **A Clear-marker hatch.** Not in the plan, and needed once the marker became a state every guard refuses: a marker whose run is gone (a killed process, a container replaced mid-copy) is otherwise a dead end. `clearRelocationMarker` removes the marker and nothing else — no link touched, no config rewritten, nothing deleted — so what `inspect()` says afterwards is the truth the disk was already telling. - **Root containment is a rule, not a hint.** `relocationRootProblem` refuses blank, relative, inside-the-corpus and resolves-into-the-channel-dir. The plan's UI copy ("an absolute directory that already exists") described `transcripts/channels` almost word for word. - **`sanitizeStorage` DROPS a relative root to blank rather than resolving it** (`common/lib/settings.ts`). Resolving would anchor the default to whatever cwd the editor booted in — a different directory under docker, under a worktree and under `pnpm dev` — so one settings.json would name three drives. Existence is deliberately not checked: the whole point of a cold root is a drive that may be unmounted when settings are read. - **`autoRunner.ts`'s lane gate kept the corpus volume**, though the plan listed it among the callers to thread. That check runs before the pick, so there is no channel yet and the lane spans 68 of them on however many volumes; the per-channel answer is taken further down in `runYtdlp`, where the slug is known and the bytes are about to be written. - **The worktree port block drifted under the work.** `pnpm wt list` assigns by position in `git worktree list`, so `storage/relocate-media` moved from index 7 to index 8 (3711/3710 → 3811/3810) while the branch was in flight, and `queue-lock --ports` only VERIFIES a block is free — it does not set it. A hardcoded block in a runbook goes stale; read `pnpm wt ports` each session, and pass the values as env. - **The e2e settings fixture is replaced, not merged, and it bit once here.** `helpers.writeSettings` overwrites `test-settings.json` wholesale, so a spec that does not name `minFreeDiskGB` inherits the PRODUCT default of 5 GB rather than the fixture's 0 — and `/home` was at 6.9 GB free. `channel-storage.spec.ts:209` failed 4/4 on exactly that (the move's space check is charged floor + resume margin = 7 GB) and was fixed by writing the key out by hand at `:219-221`. The general fix landed with the suite below, and took `widget.spec.ts` — which wanted the gate ARMED and was getting it from the same gap — with it. ### The suite **523 passed, 0 failed of 523, 22.3 min** at the merge-candidate tip `affe525`, one worker behind the machine-global queue lock, from a worktree with a composed fixture site in `export/public`. `common` is **971/971** and `tsc --noEmit` is clean in both packages. It was 522/522 at `549dd2e` before the two merge-review fixes, which added the queue-key case. The run before the fix, at `22a3b35`, was **519 passed / 2 failed** — `backfill.spec.ts:457` and `scheduler.spec.ts:29` — and the first run WITH it was 521/1, the one failure being `widget.spec.ts:591`, which wanted the disk gate armed and had been getting it by accident from the same fixture gap; it names its floor now. Neither of the original two is this branch's: `backfill.spec.ts:457` is an old `uncheck()`-did-not-take flake, reproduced twice in six repeats here; `scheduler.spec.ts:29` did not reproduce at all — 8 green runs at the same sha with a clean tree, including an exact replication of the failing command — and the tick path this branch touched cannot empty `queued` on that fixture (the media guard answers `in-place` for `slow-a`, and the disk gate measures the same volume before and after the change, proved offline). Its assertion now carries the tick's own `reason` and slow-a's own skip line, because a tick has four ways to queue nothing and all four printed the same `Received: []`.