commit 31b4bb77d3425258099e821ade09f6b97c6ab60c
parent 713a5761d50cb1afae6368dcc2f33a898847a6c1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 2 Oct 2026 01:19:39 -0400
plans: slice T2 — the mover over media/ and the surfaces, as shipped
The record (release-17.md), FACTS "Release 17 slice T2", and the editor
changelog's [Unreleased] bullets.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 218 insertions(+), 0 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -33,6 +33,8 @@
- **The operations pages share one count of the lanes' pending work.** Every open operations page asks for the lanes' status every 3 seconds, and each request used to count every lane's pending videos afresh from every channel's report. That count is now made once and handed to every request in the next 3 seconds. Changing a lane's rules, a focus or a channel's priority counts again at once; otherwise a pending count can be up to 3 seconds behind a report that was just rewritten or a video a lane just picked. A lane's hold, its runner and its picks are still read fresh on every request.
- **Jobs a stopped editor left "running" are closed when it starts again.** A job that was still running when the editor's process ended (killed, crashed, or shut down before the job had finished unwinding) kept "running" in its record for good, and `/jobs` listed it as archived. On start the editor now marks each one **cancelled**, with "interrupted: the process running it stopped before it finished" as the reason on the job's page, and its end time is the last time its log was written. Nothing is run again; **Retry** works as for any cancelled job. A job that another live process is running, such as `archilyzer run`, is left alone, and the same check now keeps the start-up pass from closing that process's queued jobs. Such leftover jobs never blocked a media move.
- **A channel's text stays on the fast disk when its media moves, so a slow or unplugged media drive no longer holds its transcripts.** A channel's big files — the audio and the raw live-chat replay — can now live in the channel's own `media` folder, on this disk or another, while its transcripts, cues, metadata and every other small file stay in `data/` where they always were; each big file that moves leaves a small link behind, so everything that opens it by name still finds it. New downloads, transcodes and live-chat normalizes put their big files there as they finish, and every cleanup that deletes audio removes the file the link points to, not just the link. What that changes when a media drive is stalled, unplugged or mid-move: **the index and stats builds never wait on it or are held by it** (a live chat whose transcript cues are out of date keeps the cues the last build read until the drive answers), the channel's report still refreshes (its media size reads as unknown until the drive answers), **digests keep running — even during a move of that channel's media** — and so do normalize, the availability checks, the metadata scan and clip eviction. Transcription, downloads, the backfill lane and anything else that opens the audio are held as before. **A channel moved the old way — its whole `data/` on the other drive — is now shown as "Media layout retired" and held by everything, the builds and digests included, until `archilyzer storage migrate-tier <channel>` brings its text home;** every refusal says so. Deleting a video from its page is refused while its channel's media drive is not reachable, so its audio is never left behind on the drive. Needs a rebuild and restart of the editor.
+- **Moving a channel's media now moves only its big files.** The Storage panel's **Move media** copies the channel's audio and raw live-chat replays to `<root>/<channel>/media` on the destination and leaves its transcripts, metadata and every other small file in `data/` on this disk; a channel whose audio is still in `data/` has it put into the channel's `media` folder on this disk first, and the preview says how many files ("2 file(s) tiered first"). **Move back in place** brings the `media` folder home; the links in `data/` are not touched either way. The panel shows the media path and the text path, each with its size. While a channel's media is held — moving, or on a drive that is unplugged or not answering — its text stays readable: the video page and the videos list open as usual and list the audio with its size unknown, the transcript opens, the audio answers "not reachable, try again" (503) instead of "not found", and a digest may run, during a move too; only the jobs and lanes that open the audio wait. A move, its preview and **Resume move** refuse a channel still on the retired whole-directory layout and name `archilyzer storage migrate-tier`; `/storage` counts such channels as unreachable "(n to migrate)". On `/storage` a location's figure is now the media on it, and the corpus volume's row adds every channel's text and clip windows ("text … + clips … on the corpus volume, plus … media of in-place channels"). Re-pointing a location, renaming a channel and deleting one follow the `media` folder. Needs a rebuild and restart of the editor.
+- **An export build no longer reads a raw live-chat replay from a media drive that is unplugged, not answering or mid-move.** It publishes the live-chat cues already on disk for that channel, and its log says how many are older than their replay.
## [0.11.0] - 2026-09-30
- **Transcripts that arrived after a video was first seen are counted.** The stats behind the homepage, the hub and every site's charts were cached per video and refreshed only when the video's metadata changed, so a transcript that came later — a Whisper run days after the download, or a video downloaded after the last index build — never reached them, and a video with YouTube captions alone had no transcription date. Counts and charts were low; the homepage could show a site with 0 transcripts, 0 channels and 0 hours while it served its videos. A stat is now also redone whenever the index re-reads the video, every transcript has a date, and a captioned video is dated by when its captions arrived rather than by a later Normalize run, so its place on "Transcribed over time" can move. **After updating, rebuild and restart the editor before anything else:** until then, **Build stats dataset** runs the old code and would undo the new stats, while a site, hub or homepage build already runs the new code — and the first stats build of any kind re-reads every video once (about 10–30 minutes on a large archive; it can be stopped and picks up where it stopped). Then build the index, the stats, the homepage, the hub, and the sites.
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -3917,6 +3917,64 @@ as they were measured.
`channelTextStall` on the retired `dataDir` only. `readChannelStat` and the recency tail reads pass
a drive only for a legacy channel. `rootOfUnknownPath` strips `<root>/<slug>/media` and `/data`.
+
+### Release 17 slice T2 — the mover over `media/`, and the surfaces (2026-10-02)
+
+Supersedes, where they differ, the mover and storage-location facts above (the whole-`data/` move,
+`relocatedDataDir`, `config.dataDir` as the location key).
+- **The mover's unit is `channels/<slug>/media`** (`controller/relocateChannelMedia.ts`). Out: the
+ job's first step asks the writers, then a classic channel is tiered in place
+ (`tierChannelMedia(paths, slug, { createMediaDir: true })`, only in the `copy` phase — a resumed run
+ tiers nothing, its marker stands), then `media/` is measured, copied to `<root>/<slug>/media`
+ (`relocatedMediaDir`), mirrored and verified, renamed to `media.relocated-<ts>`, replaced by ONE
+ absolute link, and `config.mediaDir` written. Back: `<root>/<slug>/media` → `media.incoming` → the
+ link swapped for the real dir, `mediaDir` unset; the per-file links are never rewritten. The marker
+ carries `scope: "media"` (and `readDirMarker` now keeps a `scope` it reads). Refused before anything:
+ a `legacy` channel (a `data` link or `config.dataDir`, the sentence names `archilyzer storage
+ migrate-tier <slug>`) and a marker with `scope: "tier-migration"`. `rootOfRelocatedMediaDir`
+ replaces `rootOfRelocatedDataDir`; `relocatedDataDir` (`lib/channelMedia.ts`) is kept only as the
+ retired shape for the migration and the tests. The containment check resolves the deepest EXISTING
+ ancestor of `<root>/<slug>/media` (it usually does not exist yet), so a `<slug>` level linked back
+ into the channel dir is still refused before the preview tiers anything.
+- **The preview tiers too** — an in-place channel with a `data/`, no marker — and reports
+ `tieredFirst`; a second preview tiers nothing. The result carries `tiered`, and the job's done line
+ says "(N file(s) tiered first)".
+- **A move is held by the channel's MEDIA writers only.** `channelMediaWriters(slug, opts)`
+ (`relocateChannelMedia.ts`) = `channelWriters` minus every job whose kind is not `needsMedia` —
+ except `relocate-channel-media` itself (its kind is not `needsMedia`, and must not be, but it is the
+ thing writing into `media/`) — minus the digest lane's units. The job's first step (ignoring its own
+ kind), the third ask under the marker, the preview, and the editor's
+ `channelMediaBusyReason(slug, what, { mediaOnly: true })` for Move, Move back, Resume, Reconcile,
+ Clear marker, the bulk move and the panel's blocked line ask it. Rename and delete still ask every
+ writer.
+- **Location membership keys on `config.mediaDir`, or a legacy channel's retired `dataDir`**:
+ `channelsOnLocation` (with `textBytes`, `unknownTextBytes` and a `legacy` count inside
+ `unreachable`), `channelVolumeOf(config, locations)` (now takes the config), the storage watch (one
+ auto-pause per channel, as before), `deleteChannel` (removes both targets). The re-point rewrites the
+ `media` link and `mediaDir`, and refuses a legacy channel on the location naming `migrate-tier`. The
+ rename moves a convention-shaped `<root>/<slug>/media` and re-points the `media` link.
+- **`/storage` bytes.** A location row's figure is its channels' `totalMediaBytes` (the media tier),
+ with no clips (`clipsBytes` 0 there); the internal row is in-place media + every channel's
+ `totalTextBytes` + `totalClipsBytes` (`internal.corpus`, folded in `editor/app/storage/buildStorage.ts`;
+ a report with no text figure counts unmeasured, once per channel) with the breakdown in `tiersText`
+ (`aria-label="location tier bytes"`). `channelsText` adds "(n to migrate)".
+- **The two video pages read the text on the corpus disk.** They gate on `channelTextStall` (a legacy
+ channel only), not `channelMediaStall`, and list a directory through ONE loader,
+ `loadVideoDirFiles(videoDir, mediaDir)` (`editor/app/channels/[slug]/lib/videoDirFiles.ts`): links
+ are listed (a dirent `isFile()` hid them), a link's time is its `lstat` (the hook copies the file's
+ times onto it), and the media links' sizes are asked of the media drive in ONE `onDrive(mediaDir)`
+ call; unreachable → `VideoFile.size: null`, rendered "— (media drive not reachable)".
+- **The file route picks the drive by the file's kind** (`classifyEntry(name) === "media"` →
+ `mediaDir`, else none; a legacy channel's retired `dataDir` for both) and answers **503 with
+ `retry-after: 15`** when the stat fails on a path whose `lstat` is a symlink — a tiered file whose
+ drive is away — never 404. `deleteVideoFileAction` refuses such a file rather than unlink the link
+ alone.
+- **The live-chat archive (export build) reads no raw replay while the channel's media is not
+ `ok`/`in-place`**: it stages the cues on disk as they are (`isLiveChatCuesFresh` `lstat`s the link)
+ and logs how many are older than their replay.
+- **`noCorpusWalkInRenderPaths.test.ts` greps render-path files for the walkers' NAMES**, comments
+ included: naming `measureTree` in a comment of a file a page imports fails it.
+
## Channel priority (verified 2026-09-11) — one tier per channel, four compiled trees
Branch `channel-priority/s5`, off S0's `28bfee3`, merging `s1`–`s4` and closing the twelve
diff --git a/plans/release-17.md b/plans/release-17.md
@@ -1431,4 +1431,162 @@ the full suite was clean at load 10 — this branch does not touch `scripts/`);
92 s; **e2e (the 8 specs) 78 passed, 5 failed, 8.2 min** — the same five `channel-storage.spec.ts` cases
(:80, :250, :391, :484, :1091), the old mover's layout reading `legacy`, nothing else red.
+### Slice T2, as shipped — the mover over `media/`, and the surfaces (2026-10-02)
+
+Branch `r17/media-tier-mover` off `main` `a395aaa1` (U1, XP, D0 and T1 merged), worktree
+`~/Projects/r12-paths-fix` (editor 5001, test 5011, export 5010), one Opus implementer, beside U2 and RL.
+Scratch files `T2-*` in the job's `tmp`. The plan is "The model (A′)" §5 and §6, the `loadVideoDir` change
+in §2, the `onDrive`-by-file-kind editor changes in §4, and the T2 row; T1's "left for T2" items are below.
+
+**What it does.**
+- **The mover carries `channels/<slug>/media`, never `data/`** (`relocateChannelMedia.ts`). Out: the job's
+ first step asks the writers, then a classic channel is tiered in place (`tierChannelMedia(paths, slug,
+ { createMediaDir: true })` — same-disk renames and links; only in the copy phase: a resumed run's marker
+ stands and the hook writes nothing under one), then `media/` is measured, copied to
+ `<root>/<slug>/media`, mirrored, verified, parked as `media.relocated-<ts>`, replaced by ONE absolute
+ link, and `config.mediaDir` written. Back: the target → `media.incoming` → the link swapped for a real
+ `media/` on the corpus disk, `mediaDir` unset, the target reclaimed; not one per-file link is touched in
+ either direction. The marker keeps its shape plus `scope: "media"` (`readDirMarker` now keeps a `scope`
+ it reads, so a resume rewrites what it found). The space check is `media/`'s bytes plus the margin.
+ Refused with nothing touched: a `legacy` channel (`… cannot be moved: its media layout is the retired
+ whole-directory one — run archilyzer storage migrate-tier <slug>.`), out, back and in the preview; a
+ marker whose `scope` is `tier-migration`. `relocationRootProblem`, `assertRelocationRootPresent` and
+ `rootOfRelocatedMediaDir` (renamed from `…DataDir`) work on `<root>/<slug>/media`; `relocateDir.ts`'s
+ names follow. The result carries `tiered`; the job's done line says "(N file(s) tiered first)".
+- **The preview tiers an in-place classic channel too** (with a `data/`, no marker, no `mediaDir`) and
+ reports `tieredFirst`, shown as "N file(s) tiered first"; a second preview tiers nothing.
+- **A move is held by the channel's media writers only.** `channelMediaWriters` (new, in the mover) is
+ `channelWriters` minus every job whose kind is not `needsMedia` — except `relocate-channel-media`, the
+ move itself — and minus the digest lane's units. The job's first step and the ask under the marker, the
+ preview, and the editor's `channelMediaBusyReason(slug, what, { mediaOnly: true })` for Move, Move
+ back, Resume, Reconcile, Clear marker, the bulk move and the panel's blocked line ask it; rename and
+ delete still ask every writer.
+- **Locations key on `mediaDir`** (a legacy channel on its retired `dataDir`): `channelsOnLocation`
+ (new `textBytes`, `unknownTextBytes`, `legacy`), `channelVolumeOf(config, …)`, the storage watch (one
+ auto-pause per channel, as before), the re-point (rewrites the `media` link and `mediaDir`; a legacy
+ channel on the location is refused naming `migrate-tier`), the rename (a convention-shaped
+ `<root>/<slug>/media` moves and the `media` link is re-pointed; the per-file links are relative and
+ move with the channel dir), `deleteChannel` (removes `mediaDir`, and a legacy channel's `dataDir`).
+- **`/storage`**: a location row's figure is its channels' media tier, with no clips; the internal row is
+ in-place media + every channel's text + every channel's clip windows (`internal.corpus`, folded in
+ `buildStorage.ts`), with the breakdown "text N + clips N on the corpus volume, plus N media of in-place
+ channels" (`aria-label="location tier bytes"`); a report with no `totalTextBytes` counts unmeasured,
+ once per channel; `legacy` counts as unreachable with "(n to migrate)". The page's and the re-point's
+ prose name `mediaDir`. SETTINGS.md regenerated (one field doc names `config.mediaDir`).
+- **The Storage panel**: two rows under one heading — `media path` (the target, or `<channel>/media (in
+ place)`) with `media bytes` (`totalMediaBytes`, the tier), and `text path` with `text bytes`
+ (`totalTextBytes`); `free on media volume` kept; the buttons keep their names; the hold sentence says
+ "Its text stays readable: the video pages, the index and the digests go on." (or that the text is held
+ too, when it is); the stale-marker paragraph says the media lanes skip the channel. A legacy channel's
+ Move back is refused with the migrate-tier sentence (`move back refused`). Resume refuses a
+ tier-migration marker. The badge's `legacy` entry is "Media layout retired" (danger; T1 added it for
+ tsc). The Configure form's read-only line shows `mediaDir`, a legacy `dataDir` with the command, or "In
+ the channel directory (media/)".
+- **The two video pages read the text on the corpus disk**: they gate on `channelTextStall` (a legacy
+ channel only), and list a directory through one loader, `loadVideoDirFiles(videoDir, mediaDir)`
+ (`editor/app/channels/[slug]/lib/videoDirFiles.ts`, replacing both pages' `isFile()` loaders): links are
+ listed, a link's time is its `lstat`, the media links' sizes come from ONE `onDrive(mediaDir)` call, and
+ an unreachable one is `size: null` ("— (media drive not reachable)"). The file route chooses the drive
+ by `classifyEntry(name)` and answers **503 with `retry-after: 15`** for a link whose target is not
+ there. A file delete refuses a tiered file whose drive is away (removing the link alone would orphan the
+ bytes).
+
+**T1's "left for T2", answered.**
+- `loadVideoDir` and `videos/page.tsx` filtered `isFile()`: replaced by `loadVideoDirFiles` (above).
+- The `/storage` rollups counted clips inside media bytes: a location row has none now; the internal row
+ holds every channel's clips and text.
+- The mover writes `scope: "media"` on every marker.
+- `relocatedDataDir` stays exported, documented as the retired shape (the migration and the tests build
+ it); nothing moves a channel to it.
+- The hook's marker re-check before the name changes: verified in `mediaTier-server.ts` (`markerStands`
+ is asked again after the bytes land; a move that began meanwhile leaves the file real). Corpus-wide
+ callers carry no slug, so the hook asks the marker beside the `media` it is writing into — as T1
+ shipped it; nothing to add.
+- `archiveLiveChat` read a stale raw replay with no channel guard. Ruling taken here: the build asks the
+ channel's media once (`inspectChannelMedia`, fresh); while it is not `ok`/`in-place` it reads no raw
+ replay and stages the cues already on disk (the freshness check `lstat`s the link), logging how many
+ are older than their replay; a video with no cues yet is left out of that build. Not a refusal: a
+ build never fails over a media drive.
+
+**Small edits outside the T2 row** (each needed by the surfaces above): `common/lib/channelMedia.ts`
+(the `relocatedDataDir` comment), `common/lib/mediaTier-server.ts` and `common/lib/savedVideoStore.ts`
+(comments naming the retired shape), `common/jobs/jobKinds.ts` (one comment), `common/lib/
+storageLocations.ts` (comments and one field doc), `editor/app/channels/lib/mediaBusy.ts` (`mediaOnly`),
+`editor/app/channels/[slug]/videos/[id]/components/cards/{videoFiles.ts,FilesList.tsx}` (`size: null`),
+`editor/app/channels/components/{ChannelForm.tsx,ChannelVolumeBar.tsx}`, `editor/app/{page.tsx,channels/
+page.tsx}` (`channelVolumeOf(config)`), `editor/app/storage/{page.tsx,actions.ts,components/
+StorageLocationsTable.tsx}` (prose, the tier line), `common/controller/archiveLiveChat.ts` (T1's item 4).
+No helper was added to T1's `mediaTier-server.ts` or `channelMedia.ts`.
+
+**Deviations from the plan** (one sentence each):
+1. `channelMediaWriters` lives in the mover rather than as T1's `channelWriters(…, { mediaOnly })` alone:
+ `relocate-channel-media` is not `needsMedia`, so `mediaOnly` dropped a running move and the panel
+ would have offered Clear marker, and the preview a second move, over it.
+2. The preview tiers only an in-place channel (no `mediaDir`): a relocated channel's tier is on the far
+ drive, and a preview copies nothing there.
+3. Locations, the volume column, the watch and `deleteChannel` place a legacy channel by its retired
+ `dataDir`, so `/storage` can count it "(n to migrate)" on the drive it is actually on.
+4. The containment check resolves the deepest EXISTING ancestor of the target (it used `realpath` or a
+ lexical path): `<root>/<slug>/media` usually does not exist yet, so a `<slug>` level linked back into
+ the channel dir was caught only by the mirror's direction check, after the preflight had tiered.
+5. "The digest lane still picks the channel" is pinned in e2e as a digest JOB that runs on a channel
+ whose media drive is away (the lane's decision is T1's `isChannelHeldForLane`, pinned in its unit
+ tests): the fixture has no digest-lane work without a policy and a model.
+6. Two new e2e cases rather than more steps in the main one: the legacy channel (badge, panel refusal,
+ Sync all's skip naming `migrate-tier`) and the digest.
+
+**Commits**
+
+| Commit | What |
+|---|---|
+| `6407b697` | `common:` the mover over `media/` (tier first, `media.*` parked names, `scope`, `mediaDir`, legacy and tier-migration refused), `relocateDir.ts` names, the re-point, rename, `deleteChannel`, the watch and the rollups on `mediaDir`, `/storage`'s tiers and "(n to migrate)", `channelVolumeOf(config)`; the 28 T1 skips rebased |
+| `c93048a7` | `editor:` the Storage panel's two rows, the badge, Resume/bulk/job wording, `loadVideoDirFiles`, the file route's drive by kind and its 503, the guarded file delete, the Configure line |
+| `438911c5` | `common:` the live-chat archive reads no raw replay while the media is away |
+| `e4cab18e` | `common, editor:` `channelMediaWriters` — a move is held by the media writers only; the `/storage` prose |
+| `f93caa69` | `editor:` e2e rebased on the media tier, two new cases |
+| `8d9d11f9` | `editor:` the loader's comment names no corpus walker (`noCorpusWalkInRenderPaths`) |
+| `12339ae2` | `common, editor:` the re-point refuses a legacy channel naming `migrate-tier`; the reconcile spec's changed file differs in length |
+| `46f6b51e` | merge `main` `0a62bf74` (U2, the deck's finale) — clean |
+| this commit | `plans:` this section, FACTS "Release 17 slice T2", the editor changelog |
+
+#### Gates (logs `$T/T2-*.log`)
+
+- **tsc** (all workspaces) clean at every commit and on the merged tree.
+- **common:** before the merge **2,608 passed, 1 failed, 0 skipped** (2,609) — the failure was this slice's
+ (`noCorpusWalkInRenderPaths`: a comment named the walker), fixed in `8d9d11f9`; on the merged tree
+ **2,610 passed, 0 failed, 0 skipped** (2,610). **The 28 tests T1 skipped all run** (none skipped
+ anywhere): `relocateChannelMedia.test.ts` 13, `renameChannel.test.ts` 1, `storageLocations.test.ts` 7,
+ `storageWatch.test.ts` 7 (T1's record lists 7 there; 28 in all). New: `relocateChannelMedia.test.ts`
+ 40 → 44 (a channel tiered in place moves without tiering; a legacy channel refused out, back and in the
+ preview; a tier-migration marker refused; `channelMediaWriters`) plus the rebased preview case (tiers,
+ idempotent) and back case (a real `media/`, the link untouched, the next move tiers nothing);
+ `storageLocations.test.ts` 14 → 16 (a legacy channel counted unreachable and to migrate with the tier
+ sums; the re-point refuses it); `channels.test.ts` 10 → 11 (a legacy delete); `views/storage.test.ts`
+ 16 → 18; `channelRow.test.ts` rebased.
+- **Editor unit:** 109/109 (before and after the merge).
+- **test:scripts:** before the merge 394 passed, 2 skipped (396); on the merged tree 461 passed, 2
+ skipped (463 — U2 and the deck's tests came with `main`).
+- **Build:** the capped editor build (`systemd-run --scope -p MemoryMax=6G`): exit 0, 39 s before the
+ merge, 53 s on the merged tree.
+- **e2e** (from the worktree root, `$T/T2-specs.txt`: channel-storage, storage-locations,
+ channels-storage-columns, bulk-actions, maybe-missing, video-page, fetch-window, channel-rename):
+ run 1 (before the merge) **67 passed, 1 failed, 4.9 min** — the reconcile case's "changed" file had the
+ same size as the source's and was written in the same second, so rsync's quick check called it
+ unchanged (a fixture fault, fixed in `12339ae2`); run 2, `channel-storage.spec.ts` alone, **15 passed,
+ 0 failed, 2.1 min**; run 3 on the merged tree (`46f6b51e` + this record) **68 passed, 0 failed, 3.9 min** (after a few minutes in the queue behind another worktree's suite).
+- **Privacy gate:** 0 added lines carry the user or host name (`git diff main`, counts only; the one file the whole-file grep names is `plans/FACTS.md`, with the same count as on `main`). No identifier ends in the refused parent suffix.
+- **Numbers tool:** none.
+
+#### Found and left
+
+- `HELD_REASON.inconsistent` (`lib/channelMediaHold.ts`, T1's) still says "its data link and its config
+ disagree"; since release 17 it is the media link. Wording only.
+- A move interrupted DURING its preflight's tiering leaves no marker; the rerun tiers the rest. A file a
+ writer finishes while a marker stands stays real in `data/` (on the corpus disk) until the next hook
+ sweep after the move tiers it onto the far drive — by design (the hook writes nothing under a marker).
+- `removeVideoDirMedia` still cannot clear `media/<id>/` while the drive is unmounted (T1's note); the
+ video page's file delete now refuses that case for one file, and the directory delete already did.
+- For T3: the migration writes `mediaDir` and unsets `dataDir`; everything in this slice reads a channel
+ with `mediaDir` and no `dataDir` as relocated on the new layout, and a channel with both as legacy.
+
## Rollout