"use client"; import Link from "next/link"; import { useState } from "react"; import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import type { SavedVideosView } from "yt-dlp-transcript-common/views/storage"; import { cancelJobAction } from "../../jobs/actions"; import { clearSavedVideosMarkerAction, relocateSavedVideosAction, resumeSavedVideosRelocationAction, } from "../actions"; // THE SAVED-VIDEO STORE — the one large thing in the corpus no channel move // could ever reach. // // It gets a card rather than a row in the locations table because it is not a // location: nothing lives "on" it, it has no volume identity of its own and // there is nothing to re-point. What it has is a place (in the corpus, or on a // location) and one verb for changing it. // // ⚠️ NO RUN PANEL IS EVER UNMOUNTED BY ITS OWN RESULT. StreamActionLog holds // its streamed log in React state and calls router.refresh() the instant a run // ends (plans/FACTS.md) — and that refresh re-renders this card with the store // on its new location: a different destination list, and for a Resume the very // marker the button was offered for now gone. The Move panel is therefore // rendered UNCONDITIONALLY (only its `disabled` changes), and the Resume // section follows the documented `ranHere` shape — a flag set inside its own // trigger, so it stays for its log with the now-cleared condition fed to // `disabled` rather than becoming a second Run button. export function SavedVideosStoreCard({ store }: { store: SavedVideosView }) { const [destId, setDestId] = useState(() => store.destinations[0]?.id ?? ""); const [ranResumeHere, setRanResumeHere] = useState(false); const [note, setNote] = useState(null); const [error, setError] = useState(null); // DERIVED, not the raw state: a finished move changes the destination list // under this card, and a select still naming the location the store is now // ON would be offering a no-op. const valid = store.destinations.some((d) => d.id === destId); const effectiveId = valid ? destId : (store.destinations[0]?.id ?? ""); const canMove = store.busy === null && store.destinations.length > 0 && store.status !== "in-transition" && store.status !== "inconsistent"; return (

Saved-video store

{store.statusLabel}

Where persisted source containers live. No channel move reaches it — it belongs to the corpus, not to a channel — so it moves on its own, by the same mechanism: the copy is verified before the source is touched, and a symlink is left behind so{" "} every reader {" "} keeps working unchanged.

On
{store.locationLabel}
Path
{store.at} {store.at !== store.dir ? ` (read as ${store.dir})` : ""}
Size
{store.status === "unreachable" || store.status === "in-transition" ? "—" : `${formatBytes(store.bytes)} in ${store.files.toLocaleString()} file(s)`}
{store.detail && (

{store.detail}

)} {store.busy && (

{store.busy}

)} {note && (

{note}

)} {error && (

{error}

)}
relocateSavedVideosAction(effectiveId)} cancelAction={cancelJobAction} buttonLabel="Move the store" runningLabel="Moving the store…" label="Move the store" disabled={!canMove} />
{/* THE TWO WAYS OUT OF A MARKER WHOSE RUN IS GONE — finish it, or throw it away. Offered only when a marker is present AND nothing is running: with a live job the marker is not stale, it belongs to that run. */} {(store.canResume || ranResumeHere) && (

Finish the interrupted move, or clear its marker

{store.canResume && (

Resume 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. Clear marker removes the marker file and nothing else: no link, no settings, no bytes.

)}
{ setRanResumeHere(true); return resumeSavedVideosRelocationAction(); }} cancelAction={cancelJobAction} buttonLabel="Resume move" runningLabel="Resuming…" label="Resume move" disabled={!store.canResume} />
)}
); }