commit ecee31edffc4929f0096c41c994479683ba976a4
parent cb273103f2a0e386f1cb9ef817441e83b44dc637
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 17 Sep 2026 20:59:43 -0400
docs: storage locations, and what a container can and cannot know about a disk
RUNNING_IN_DOCKER.md gains the half of /storage that does not work in a container and why that is not a downgrade: no block devices means no UUID, so Mounted elsewhere / Not mounted / Not attached are never reported, every identity probe fails open to unknown, and the remedy is to change the location's root to where the media actually is — or to fix the bind mount so nothing needs re-pointing.
AGENTS.md's corpus section names settings.storage.locations and /storage, and states the derivation the whole design rests on: a channel is not tagged with its location, it is on L iff config.dataDir is under L.root.
The changelog entry covers S0-S4 as one release: the popover fix, the lane/registry busy check, the directory-mtime verify retry, the locations entity and its migration from mediaRoot, /storage itself, and the destination select, badge and Resume move.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 38 insertions(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -177,6 +177,11 @@ apps*, not [DEPLOY_DOCKER.md](DEPLOY_DOCKER.md), which is about *building sites*
| `transcripts/saved-videos/` | Persisted source-video store. |
| `transcripts/search-aliases.json`, `duplicates*.json` | Corpus-wide curated data. |
+**The roots a channel's media may be moved to are named entities**, `settings.storage.locations`
+(id, label, root, `autoRepoint`, and the volume UUID learned at the last probe) — managed on
+**`/storage`**, which reports whether each one is mounted and can re-point a whole location to a
+new path without moving a byte. They migrated from the single `settings.storage.mediaRoot` string.
+
**The public-URL key in `site.json` is `siteUrl`.** The editor form labels the field
"Public URL", so grepping for `publicUrl` finds the UI hint and misses the data.
@@ -184,7 +189,10 @@ apps*, not [DEPLOY_DOCKER.md](DEPLOY_DOCKER.md), which is about *building sites*
`channels/<slug>/data` can be an **absolute symlink** to `<root>/<slug>/data` on
another disk, with `config.dataDir` recording the target. The editor's Storage panel
-(channel page → Storage) moves it; nothing else writes that field. The on-disk contract
+(channel page → Storage) moves it — to one of the locations configured on `/storage`,
+or to a root typed by hand; nothing else writes that field. **A channel is not tagged
+with its location**: it is on location L iff its `config.dataDir` is under `L.root`,
+which is why re-pointing a location rewrites only links and `dataDir`. The on-disk contract
`channelDir/data/<id>/…` is unchanged, so **no reader needs to know** — yt-dlp's
cwd-relative writes, the LMDB index (it stores mtimes, and `rsync -a` preserves them)
and the export build all keep working with no call-site changes.
diff --git a/RUNNING_IN_DOCKER.md b/RUNNING_IN_DOCKER.md
@@ -397,6 +397,34 @@ its report is not regenerated, rather than the alternative, which is every count
channel reading zero and the download runner treating the whole archive as missing. The
badge on `/channels` and the channel's Storage panel name the path they cannot reach.
+#### Storage locations in a container: re-point by path, and that is the whole story
+
+`/storage` names each media root as a **location** and reports whether it is there. On
+a host it can do more than that: it learns the volume's filesystem UUID from `findmnt`,
+so when a drive comes back at a different mountpoint the page offers **Re-point** and
+the operator takes it in one click.
+
+**Inside a container none of that identity exists.** Block devices are not passed
+through, so `findmnt -J -T <root>` describes the bind mount and not the disk behind it:
+no UUID, no `/dev/disk/by-uuid` entry, nothing to mount with `udisksctl`. Every identity
+probe **fails open to "unknown"** — deliberately, because a probe that turned "I could
+not ask" into "your disk is gone" would declare every containerised corpus broken. What
+a location reports here is what `stat` says and nothing more:
+
+| Status | What it means in a container |
+|---|---|
+| Available | The root is a directory. The bind mount is up. |
+| Missing | The root is not there, and there is no identity to look for. |
+| Mounted elsewhere / Not mounted / Not attached | **Never reported.** They all need a UUID. |
+
+So the container's remedy is the manual one, and it is not a downgrade: **change the
+location's root to the path the media is actually at, and re-point.** Edit the location
+on `/storage`, or press Re-point after correcting the root; the job rewrites each
+channel's `data/` symlink and its `config.dataDir` and moves no bytes. Equivalently,
+fix the compose file so the bind mount lands where the editor recorded — the same
+`-v /host/path:/container/path` line above — and nothing needs re-pointing at all.
+`autoRepoint` has nothing to act on here and can stay off.
+
The `site` profile does not need the mount: an export build never reads `data/`.
### Useful commands
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **The drives a channel's media lives on are named places now, and one click re-points them.** The cold root used to be a single string typed into Settings, and a relocated channel's `config.dataDir` an absolute path — so when the platter was automounted at `/run/media/user/<uuid>` and came back somewhere else, every channel on it read *unreachable* and the only remedy was SSH and hand edits. **`/storage`** (twelfth entry, under Machine) lists each media root as a **location** with a name, a status and the channels on it: `Available`, `Not mounted`, `Not attached`, or **`Mounted elsewhere`** — which is the one that matters, because it means the disk is here under a different mountpoint, and the row then offers **Re-point**, which rewrites every channel's `data/` symlink and `config.dataDir` and **moves no bytes at all**. There is a **Refresh** per row (a probe is `findmnt`, memoised for ten seconds, and it never writes availability to `settings.json`), a **Mount** for an attached-but-unmounted volume, a per-location **auto re-point** opt-in for operators who would rather it just happened, and a boot pass that checks every location as the editor starts. Identity is the volume's filesystem **UUID**, learned at the last successful probe — in a container there are no block devices to learn it from, every probe **fails open to "unknown", and re-point by path is the whole story** (see RUNNING_IN_DOCKER.md). The old `settings.storage.mediaRoot` **migrates on read** into a one-entry list called *Default*; the Settings field is now a link to the page. Everywhere a move starts, the destination is a **name picked from a list** rather than a path retyped per channel: the channel's Storage panel has a **destination select** showing each drive's current state (with *Another root…* keeping the free-text box), and `/channels`' selection deck has the same select for a whole batch — and what reaches the server is the **id**, never the root, so a page rendered before a re-point cannot aim a batch at a root that has since moved. The badge on every channel row says **`on Platter`** instead of sixty columns of absolute path, or **`on Platter — unreachable`** when the drive is not there. **And an interrupted move can be finished.** The controller has always resumed a half-done copy; nothing in the editor could reach it, so the only offered way out of a killed rsync was *Clear marker* and a full re-copy — which for the incident behind this work meant re-copying 131 GB that was already correctly on the far side. The panel now offers **Resume move** beside it, and the same release closes the three ways that incident happened: the relocate copy raced an auto-queue digest unit that made **no job record**, so "is this channel busy" now asks the lanes as well as the registry, and a lane that finds a relocation marker stops instead of writing into a directory being copied; a sidecar written mid-copy left one directory timestamp differing and `verifyCopy` refused the whole 131 GB, so a drift that is *only* directory mtimes now gets one more `rsync -a` pass and a re-verify (content drift still refuses, and still says the source has not been touched). Also fixed: on `/channels` a dimmed row's **Advanced menu drew underneath the rows below it** — `opacity` on a `<tr>` makes a stacking context, so the row is dimmed cell by cell now, and the cell hosting the popover is left alone.
- **The operations poll reads the auto-queue's state file once instead of four times.** Every payload the editor draws — the jobs head, the workers grid, the operations board, the sync schedule, the widget's tiles, the pulse token — used to be computed by a function that did its own reading, so each of the four lanes on `/operations` opened `.auto-queue/state.json` for itself: four parses of the same document every three seconds, on a page whose four lanes were always reading one document. Those builders are pure functions in the shared core now — they are handed the settings, the registry, the scheduler, the pool, the clock and their readings, and they cannot reach disk or construct a singleton, which a layer test enforces rather than a comment asking nicely. The reading happens once, at the edge, and is shared. **Nothing moved that you can see**: same pages, same URLs, same JSON on every endpoint, same numbers — the difference is that each payload now has unit tests of its own (the console's cooldown filter, the pulse token's sensitivity, the workers grid's task grouping), where previously the only way to test one was to render the page that showed it.
- **`/channels` is a rack now, with one selection deck and a meter bridge.** The page had two selection bars for one selection — a floating one for Tier and Focus, and a second block below sixty-seven rows for Move media, both saying "N selected" and both offering Clear. There is **one deck**: it docks under the table when you tick a row, carries **Tier**, **Focus** and **Media** side by side, and unmounts when you untick. The destination root lives in its own box beside the button (the button used to carry it in its label, where it truncated to *Move media to…* and you could not read where the files were going). **The table stops spilling off the screen.** It lives in one scroll region: the column headers pin to its top, the checkbox and slug cells pin to its left, and a section's name pins under the headers — so the identity column and the meter bridge header stay on screen while sixteen columns scroll sideways. The six pipeline columns read as **one block** rather than six loose dashes: a shared *Pipeline* eyebrow, a surface behind them, a rule at each end. **Rows are 41 px instead of ~90.** The tier cell is one line, and being held by a focus is a small **held** chip rather than the same orange sentence repeated on sixty-one rows — the sentence is stated once, with a count, on the focus line above the table, and each chip still carries the full reason for a screen reader and on hover. Opening a row's *Advanced* overlays the rows below instead of pushing them down. **The page's caveat is at the top.** The note saying every number here is read from each channel's last report, and how old the oldest one is, used to be the last thing on the page in 11 px type under a floating bar; it is the subtitle beside the title now, with the channel count. The band legend and the Names·A / Names·T explainer moved above the table too, beside *Group by section*. In the header, *Sync every channel*, *Full sweep every channel* and *Update all reports* are outlines under an **Every channel** eyebrow that says what they sweep, and **New channel** is the only filled button. Nothing on disk moved and no control changed its name.
- **A channel's media can live on another drive.** A channel page has a **Storage** panel: where its media actually is, how much audio is on disk, how much room is free on the volume holding it, and **Move media to…** — give it a directory on another disk, press *Preview* to see the bytes and the free space there, and the move copies, **verifies**, and only then swaps `data/` for a link to the new location and records it. **Move back in place** reverses it. The source is never touched until the copy has verified, so a cancelled or crashed move leaves everything where it was and the partial copy resumable; re-running finishes it. Nothing else changes: every page, every job, yt-dlp and the search index read the channel exactly as before, because the path they use is unchanged. **The point is what happens when the drive is not mounted.** `data/` reads as empty then, and an empty `data/` means "nothing has been downloaded" to the download runner — an instruction to re-fetch the entire channel onto the disk that was too full to hold it. So an unreachable channel is **refused rather than guessed at**: its media jobs will not start, the four lane runners skip it (and keep running every other channel — this is not a lane stop), its report will not regenerate over an empty directory, and a red **Media unreachable** badge names the path on `/channels`, on the dashboard and on the channel itself. A relocated-and-reachable channel gets a neutral badge saying where; a channel in place gets none. The low-disk floor now measures **the volume the bytes are actually going to** rather than always the corpus disk, and holds each volume separately — a full SSD no longer pauses downloads landing on the platter. The **Media location** line on a channel's Configure form is read-only on purpose: it is a record of what is on disk, written only by a move that succeeded. The cold drive is typed **once**: **Settings → Default media root** seeds the root box in every channel's Storage panel, and `/channels` rows can now be ticked — select several and **Move media to…** queues one job per channel on that channel's own queue, so they serialize instead of fanning out, each one running its own space check at run time rather than at enqueue time (a root that fills partway through refuses the remainder cleanly, and a channel already on that root is skipped rather than failed). The default is a default and nothing more: it is never read by the move itself, which always takes an explicit root, and a relocated channel is not thereby deprioritized. **Nothing moves on its own, and nothing on disk changes until you move a channel.**