commit 9eddf8492c69ee11099ea722d3b6bbc0c55ee232
parent ecee31edffc4929f0096c41c994479683ba976a4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 18 Sep 2026 17:16:25 -0400
storage: review fixes — honest copy after a resume, and a select that agrees with itself
Eight from the S4 review.
The marker section outlives its own success so its log can be read, and its copy went on claiming the channel was "skipped by every lane" after a Resume had cleared the marker. The three paragraphs are gated on canAct now; what stays is one line saying the marker is gone.
storageLocations.ts's header contradicted its own locationOfDataDir comment forty lines down: a "use client" file may name this module for TYPES ONLY — it imports node:path, so a runtime call would pull that into a browser bundle. The header says that now, and says who does call it.
RUNNING_IN_DOCKER.md's status table was wrong about "Not attached": a settings.json authored on a host keeps volume.uuid, it survives the bind mount, and findmnt and by-uuid then both miss — so the probe does report absent in a container. It gets its own row and a sentence saying it means what Missing means there.
Plus: "Preview this destination" (the control is no longer a root box); quiet() before the Resume case's first navigation, like every other case here; the bulk action's destination is required, not optional with a default-location fallback nothing calls; both destination selects derive their value so a location deleted while the page is open does not leave a <select> silently showing its first option; and the superseded "Settings -> Default media root" sentence in the older changelog entry says so.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
7 files changed, 74 insertions(+), 37 deletions(-)
diff --git a/RUNNING_IN_DOCKER.md b/RUNNING_IN_DOCKER.md
@@ -415,7 +415,15 @@ a location reports here is what `stat` says and nothing more:
|---|---|
| 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. |
+| Not attached | The root is not there and a **recorded** UUID was found nowhere. |
+| Mounted elsewhere / Not mounted | **Never reported.** Both need a UUID the probe can find *now*. |
+
+`Not attached` does appear here, and only for one reason: a `settings.json` authored on a
+host carries the `volume.uuid` learned there, and it survives being bind-mounted into the
+container. Inside, `findmnt -S UUID=…` and `/dev/disk/by-uuid/<u>` both miss, so the probe
+reports `absent` — which reads as "Not attached" but **means exactly what Missing means
+here**: the root is not at that path. There is no disk to go looking for and nothing to
+mount; fix the path.
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
diff --git a/common/lib/storageLocations.ts b/common/lib/storageLocations.ts
@@ -3,11 +3,13 @@ import path from "node:path";
// STORAGE LOCATIONS — the named places a channel's media may live.
//
// This module is PURE, and deliberately so: it is the only storage module a
-// `"use client"` file is allowed to import (for the types, and for
-// `locationOfDataDir` when a table projects "on Platter" from a channel's
-// `dataDir`). Anything that shells out — every identity and availability probe
+// `"use client"` file may name at all — and then for its TYPES ONLY, which are
+// erased. It imports `node:path`, so a runtime call from a client component
+// would pull that into a browser bundle; the two server pages that need a
+// location's NAME for a row call `locationLabelOfDataDir` themselves and ship
+// the string. Anything that shells out — every identity and availability probe
// — lives in `storageVolumes.ts`, which imports execa and must therefore never
-// be reachable from a client component (`next build` enforces that).
+// be reachable from a client component at all (`next build` enforces that).
//
// The entity is stored in `settings.storage`. A channel is NOT tagged with its
// location: it is on location L iff its `config.dataDir` is under `L.root`.
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -4,7 +4,7 @@
- **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.**
+- **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.** *(Superseded above: **Settings → Default media root** is gone — the roots are named locations on `/storage` now, and every destination is picked from that list by name rather than typed.)*
- **Channels have priorities now, and the auto-queue's rules are generated from them.** Focusing on one group of channels — "finish Jeralyzer, hold the rest" — used to mean hand-editing four rule trees, and the only per-channel switch on `/channels` was **Sync included / excluded**, which gated sync and nothing else. Every channel row now carries a **tier** — *Normal*, *Low* or *Paused* — plus a corpus-wide **focus**: pick channels and press *Focus these*, or focus a whole site, and every lane runs the focused channels until they have nothing left, then falls through to the rest and retakes the lane the moment new focused work arrives. A focus is one fact, not four: the download, transcription, digest and speaker lanes are all held by it, and each lane's console carries a banner saying what is focused, how much of it is pending there, how many channels are held behind it, and **End focus**. Behind the disclosure on each row, any single operation can be pinned to its own tier — "keep this channel's playlist current but stop downloading it" is a *download* pin, and *Sync only* is a preset for it. A paused channel is dropped from the automatic lanes and from the sync scheduler, and **still runs from every Run button**: a hold is not a stop. Its row dims and its Build toggle is untouched, because publishing is a different question from scheduling. The four rule trees are **generated** from all of this: the policy editor on an operation's page shows them read-only with a link back to `/channels`, keeps editing everything that is not generated (enable, workers, order, the replace-auto-captions lane), and the channel leaves you had are replaced by the compiled ones. **Sync included / excluded is gone**, and it is the same statement said better: the 15 channels that carried it become *paused for sync alone* and keep every lane they were on. **Run the migration before you first start this version.** `Sync included / excluded` is a deleted field, and until the migration has moved those 15 channels to *paused for sync*, the editor reads them as having said nothing about sync — so they are back in the schedule, back in **Sync every channel**, back in each group's **Sync**, and shown as auto-sync eligible. Nothing downloads or transcribes differently, and the automatic tick only fires if your scheduler heartbeat is on, but a *Sync all* click in that window sweeps channels you had excluded. The order is: **stop the editor → `pnpm -C common exec tsx bin/migrate-channel-priority.ts` → start it again.** Run it with `--dry-run` first to see exactly what it would write, per channel, and what each row was derived from; the real run takes its own timestamped backup of `settings.json` beside the file, so there is nothing to copy by hand. After that it is a no-op — run it twice and the second run changes nothing. Your rule trees survive either way: they are what the migration reads the channel order out of, and if you set a tier before running it, the first save seeds itself from those same trees rather than replacing the order you hand-built.
- **Every pipeline is dispatched by one thing now: its lane’s runner. The two corpus sweeps and the arbiter are gone.** Digest and Speaker work were driven by a *sweep* — a corpus walk armed by its own switch, with its own scope, its own order and its own console — while Download and Transcription were driven by the auto-queue runner, with rules, a claim ladder, a next-up and a pick log. Two mechanisms, two vocabularies, two sets of bugs. There is one: **each of the four lanes has a runner, a rule list, and Start / Drain / Stop beside its pause**, on the operation’s own page. Arming a corpus pass is switching the lane on; scoping it to particular channels or operations is a *rule*, written the same way auto-transcribe’s have been written since it shipped. The dashboard and the widget keep a one-click switch per lane — **Run every channel** / **Stop the lane** where they said *Sweep every channel* / *Stop sweeping* — and the scope lives on the lane’s page, where you can see what it would do next. **Your armed scope is carried over, and no lane is switched on that was not.** The ten settings fields the sweeps used (`digest.sweepEnabled`, `sweepChannels`, `recencyOrder`, `recencyReach`; `backfill.sweepEnabled`, `sweepKinds`, `sweepChannels`, `order`, `reach`, `weight`) are read once and written into the lane’s rules the first time the editor starts: a sweep armed on three channels becomes three rules, an unscoped one becomes a single *every channel* rule, and a disarmed sweep becomes a switched-off lane. What is retired rather than migrated: **Reach**, because a rule already orders every video it claims across every channel — which rule goes first is the rule list’s job; the digest **order**, whose real meaning was always *newest day first, shortest video within a day* and which the lane spells as **Shortest first** (pick *Newest first* there if you want the date order alone); and the backfill lane’s **Resource share**, which was one number answering two different questions. A lane now stands aside for transcription when it would actually compete for the graphics card, and keeps its slots when it would not — so speaker-naming over an LLM endpoint no longer parks itself behind a transcription it was not competing with. **The arbiter, which never ran a single unit in production, is deleted**; the runner is what dispatches an operation-named rule. **Nothing on disk changes**, and the retired keys are left in `settings.json` — harmless, ignored, and yours to delete.
- **The transcode operation is gone — it never fired.** A channel page had a *Transcode* stage, `/operations/transcode` had a "no console here" panel, `/cleanup` offered "Clear failed transcodings", and the video list drew a third status dot — all for a re-encode step built against two failures that never happened in production: in 68 channels, no snapshot has ever listed a video as missing its target format, no `failed-transcodings` file has ever held an id, and only four channels even met the stage's gate. Transcription never needed it — a video whose audio is in another format transcribes from that file. What stayed is everything that was never the operation's: the download path still re-encodes what it extracts itself, the video page still offers **Transcode audio.\<ext\> → \<fmt\>** per file, and both audio-format sweeps on the Cleanup stage and `/cleanup` are unchanged (gated on the channel having an `audioFormat`, which is what they compare against). The snapshot bucket behind the sweep is `wrongFormatAudio` now — its operator-facing name — and old reports keep their stray key until their next refresh. A `?stage=transcode` bookmark opens the channel overview. **Nothing on disk changes.** Also: the Pool's running-jobs list names the eight kinds its buttons enqueue, and the site's Search aliases tab no longer carries a "no site selected" branch that could not run.
diff --git a/editor/app/channels/[slug]/components/stages/StorageStage.tsx b/editor/app/channels/[slug]/components/stages/StorageStage.tsx
@@ -296,7 +296,12 @@ function MoveOut({
<span className="font-medium">Destination</span>
<select
aria-label="destination location"
- value={destId}
+ // DERIVED, not the raw state. `destId` is seeded once; a location
+ // deleted on /storage while this page is open would leave the state
+ // naming an id that is no longer an option, and a <select> whose
+ // value matches nothing silently shows the first one. `custom` is
+ // already true in that case — this makes the control agree with it.
+ value={custom ? CUSTOM : destId}
disabled={!canMoveOut}
onChange={(e) => {
setDestId(e.target.value);
@@ -416,7 +421,7 @@ function MoveOut({
)}
{!confirmed && named && (
<p className="text-xs text-muted-foreground">
- Preview this root to enable the move.
+ Preview this destination to enable the move.
</p>
)}
<StreamActionLog
@@ -528,26 +533,41 @@ function StaleMarker({
<h3 className="text-base font-semibold">
Finish the interrupted move, or clear its marker
</h3>
- <p className="text-sm text-muted-foreground">
- A move is in flight, or one was interrupted
- {marker
- ? ` at phase "${marker.phase}" (${marker.direction === "out" ? "to" : "from"} ${marker.target})`
- : ""}
- . While the marker stands this channel is skipped by every lane and
- its media jobs are refused.
- </p>
- <p className="text-sm text-muted-foreground">
- <strong>Resume move</strong> runs the same move again from where it
- stopped — whatever already copied correctly is not copied twice, and
- the source is not touched until the copy verifies. That is the usual
- answer.
- </p>
- <p className="text-sm text-muted-foreground">
- <strong>Clear marker</strong> removes the marker file and nothing
- else: no files are moved, copied or deleted. Check what is on the
- drive first, because whatever this channel’s location reads
- afterwards is what the disk was already saying underneath.
- </p>
+ {/* THE COPY IS GATED ON `canAct`, not merely the buttons. This section
+ outlives its own success on purpose — the log of the run that just
+ finished is the thing the operator wants to read — and a Resume that
+ worked has CLEARED the marker these three paragraphs describe. Left
+ ungated they would go on claiming a channel out of transition is
+ "skipped by every lane", which is exactly the kind of confident
+ wrongness the rest of this panel exists to avoid. */}
+ {canAct ? (
+ <>
+ <p className="text-sm text-muted-foreground">
+ A move is in flight, or one was interrupted
+ {marker
+ ? ` at phase "${marker.phase}" (${marker.direction === "out" ? "to" : "from"} ${marker.target})`
+ : ""}
+ . While the marker stands this channel is skipped by every lane
+ and its media jobs are refused.
+ </p>
+ <p className="text-sm text-muted-foreground">
+ <strong>Resume move</strong> runs the same move again from where
+ it stopped — whatever already copied correctly is not copied
+ twice, and the source is not touched until the copy verifies. That
+ is the usual answer.
+ </p>
+ <p className="text-sm text-muted-foreground">
+ <strong>Clear marker</strong> removes the marker file and nothing
+ else: no files are moved, copied or deleted. Check what is on the
+ drive first, because whatever this channel’s location reads
+ afterwards is what the disk was already saying underneath.
+ </p>
+ </>
+ ) : (
+ <p role="status" className="text-sm text-muted-foreground">
+ The marker is gone — this channel is out of transition.
+ </p>
+ )}
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
diff --git a/editor/app/channels/bulkStorageActions.ts b/editor/app/channels/bulkStorageActions.ts
@@ -31,7 +31,6 @@ import path from "node:path";
import { stat } from "node:fs/promises";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
-import { defaultLocationRoot } from "yt-dlp-transcript-common/lib/storageLocations";
import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig";
import { inspectChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia";
import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels";
@@ -64,17 +63,16 @@ async function isDirectory(p: string): Promise<boolean> {
// The deck picks a name from the list /storage maintains and sends the id; the
// root is resolved HERE, from the settings, so a stale page cannot aim a batch
// at a root a re-point has moved. `__custom` and a typed root are still
-// accepted, for the one-off; omitting the destination entirely falls back to
-// the default location, which is what an older caller did.
+// accepted, for the one-off.
export async function bulkRelocateChannelMediaAction(
slugs: string[],
- dest?: MoveDestination,
+ dest: MoveDestination,
): Promise<BulkRelocateResult> {
const paths = getPaths();
- const storage = getSettings().storage;
- const resolved = dest
- ? resolveMoveDestination(dest, storage.locations)
- : { root: defaultLocationRoot(storage).trim() };
+ const resolved = resolveMoveDestination(
+ dest,
+ getSettings().storage.locations,
+ );
const refuseAll = (reason: string): BulkRelocateResult => ({
queued: [],
skipped: slugs.map((slug) => ({ slug, reason })),
diff --git a/editor/app/channels/components/ChannelSelectionDeck.tsx b/editor/app/channels/components/ChannelSelectionDeck.tsx
@@ -181,7 +181,12 @@ export function ChannelSelectionDeck({
{destinations.length > 0 && (
<select
aria-label="bulk media location"
- value={destId}
+ // DERIVED, not the raw state — see StorageStage.tsx. `destId` is
+ // seeded once; a location deleted on /storage while this page is
+ // open would leave it naming an id that is no longer an option,
+ // and a <select> whose value matches nothing silently shows the
+ // first. `custom` is already true then; this agrees with it.
+ value={custom ? CUSTOM : destId}
disabled={busy}
onChange={(e) => setDestId(e.target.value)}
className="rounded-md border border-border bg-card px-2 py-1 text-xs disabled:opacity-50"
diff --git a/editor/e2e/channel-storage.spec.ts b/editor/e2e/channel-storage.spec.ts
@@ -555,6 +555,10 @@ test("Resume move finishes an interrupted move and clears its marker", async ({
),
);
+ // Same wait the other cases make: the Resume action refuses while the channel
+ // has a queued or running job, and resetData's cache invalidation can leave
+ // one behind for a moment.
+ await quiet(page);
await page.goto(channelStage(SLUG, "storage"));
// In transition, with both escapes offered and neither move available.
await expect(page.getByLabel(/^media location: Media moving/)).toBeVisible();