Capture local files by copying them; add W to capture the queue

Two capture fixes.

Capturing already-local playables: a download capture recorded any
non-http source as skipped, so capturing an fs node or a queue mixing
streamed and local tracks produced red, audioless entries even though
the audio was on disk. fetch_track now copies a local-file source into
the capture next to its toml (source extension kept, counted against the
byte budget); a missing or unreadable source still records skipped.

Queue W: the queue pane only had w (save), so capturing the queue meant
save, navigate, then W. Shift-W in the queue now download-captures the
continuously persisted /queues/current directly.

Deferred to a later refactor: relocating the internal stores out of
.config into .local/state, and a central content-addressed audio store
so captures dedup and link instead of copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-22 01:08:29 +02:00
parent 80c4b6e7ed
commit ef69afdd5f
7 changed files with 220 additions and 29 deletions

View File

@ -133,11 +133,16 @@ live as you type — `Enter` keeps the filter, `Esc` clears it.
provider to replay) or, in the queue pane, saves the queue.
- `W` **captures** the selection: the subtree is mirrored under
`/captures/<name>` with every track's audio downloaded next to its
metadata — fully local playback afterwards. Captures are incremental:
metadata — fully local playback afterwards. In the queue pane `W`
captures the current queue directly (no need to save it first).
Tracks that are already local (from `/fs` or another capture) are
copied in rather than re-downloaded, so a queue mixing streamed and
local tracks captures completely. Captures are incremental:
re-capturing the same name resumes and completes it; tracks whose
source cannot be captured are recorded as *skipped* (red in the UI,
skipped by playback). Download captures can take long; progress is
shown in the library pane. Inside `/captures`, `d` deletes any
source genuinely cannot be captured are recorded as *skipped* (red in
the UI, skipped by playback). Download captures can take long;
progress is shown in the library pane. Inside `/captures`, `d` deletes
any
folder or single track *from disk* (audio included) after a `y/N`
confirmation.

View File

@ -80,11 +80,16 @@ The sink decides the write mode:
as done, no download). A `url`/`link` playable also counts as satisfied
— only this store writes here, but hand-edited files should not be
clobbered.
- Otherwise the track is (re)captured: a source that cannot be captured —
the track is itself skipped, its stream fails to resolve, resolves to
nothing, or to a non-http(s) target — writes a **skipped toml**
(`from_track_skipped`) and counts as skipped. This replaces the old
silent omission.
- Otherwise the track is (re)captured. A source that resolves to a
local **file** path rather than an http(s) URL — an fs playable, or a
track from an existing capture — is **copied** into the capture next
to its toml (counting against the same byte budget as a download), so
a queue mixing streamed and local tracks captures fully. A source
that genuinely cannot be captured — the track is itself skipped, its
stream fails to resolve, resolves to nothing, or the local file is
missing/unreadable — writes a **skipped toml** (`from_track_skipped`)
and counts as skipped. This replaces the old silent omission (and the
older behavior of skipping local files outright).
- A real download failure (HTTP status, transport, timeout, byte budget)
**aborts the run but keeps everything written so far** — re-running the
same name resumes exactly where it stopped, re-attempting skipped and

View File

@ -99,6 +99,11 @@ pub enum Action {
/// Open the input overlay asking for a name to save the queue under
/// (persisted as `/queues/<name>`). No-op while the queue is empty.
QueueSaveAs,
/// Open the input overlay to **download-capture** the current queue
/// straight into `/captures/<name>` (the queue equivalent of the
/// library's `W`), instead of having to save it and then capture the
/// saved copy. No-op while the queue is empty.
QueueDownloadCapture,
// Help modal
CloseHelp,
}
@ -425,6 +430,13 @@ pub const BINDINGS: &[Binding] = &[
action: Action::QueueSaveAs,
description: "Save queue under a name",
},
Binding {
scope: Scope::Queue,
mods: KeyModifiers::SHIFT,
code: KeyCode::Char('W'),
action: Action::QueueDownloadCapture,
description: "Capture the queue (download audio; same name resumes)",
},
Binding {
scope: Scope::Queue,
mods: KeyModifiers::NONE,
@ -595,7 +607,8 @@ mod tests {
),
Some(Action::LibraryCaptureNode)
);
// Shift-w is the download capture, library pane only.
// Shift-w is the download capture in each pane: the library
// selection, or the whole queue.
assert_eq!(
lookup(
UiFocus::Library,
@ -610,7 +623,7 @@ mod tests {
false,
key(KeyCode::Char('W'), KeyModifiers::SHIFT)
),
None
Some(Action::QueueDownloadCapture)
);
}

View File

@ -66,6 +66,11 @@ pub const COLOR_GREEN: Color = Color::Rgb(163, 190, 140);
// const COLOR_ORANGE: Color = Color::Rgb(208, 135, 112);
// const COLOR_BRIGHT: Color = Color::Rgb(216, 222, 233);
/// Library path of the continuously persisted live queue — what the
/// queue pane's `W` download-captures (server: `queue_store`'s
/// `CURRENT_QUEUE_NAME` under the `/queues` instance).
const CURRENT_QUEUE_PATH: &str = "/queues/current";
// FIXME: Rename this
pub enum MessageToUi {
Init(InitialData),
@ -575,6 +580,20 @@ impl App {
});
}
}
Action::QueueDownloadCapture => {
// Capture the live queue directly (its continuously
// persisted `/queues/current` snapshot), so the user need
// not save-then-capture. Empty queue: nothing to capture.
if !self.queue.is_empty() {
self.input = Some(InputState {
purpose: InputPurpose::Capture {
path: CURRENT_QUEUE_PATH.to_string(),
download: true,
},
buffer: String::new(),
});
}
}
}
DispatchResult::Continue
}
@ -1426,6 +1445,44 @@ mod tests {
assert_eq!(input.buffer, "");
}
#[test]
fn queue_download_capture_targets_the_current_queue() {
let (mut app, rx) = app();
// Empty queue: nothing to capture.
assert_eq!(
app.dispatch(Action::QueueDownloadCapture),
DispatchResult::Continue
);
assert!(app.input.is_none());
app.queue.update_queue(one_track_queue());
let _ = app.dispatch(Action::QueueDownloadCapture);
let input = app.input.as_ref().expect("capture overlay open");
assert!(
matches!(
&input.purpose,
InputPurpose::Capture { path, download: true } if path == "/queues/current"
),
"queue W download-captures /queues/current"
);
// Submitting sends a download capture of the live queue.
type_str(&mut app, "party");
app.handle_input_key(key(crossterm::event::KeyCode::Enter));
match rx.try_recv() {
Ok(MessageFromUi::CaptureNode {
path,
name,
download,
}) => {
assert_eq!(path, "/queues/current");
assert_eq!(name, "party");
assert!(download);
}
other => panic!("expected CaptureNode, got {:?}", other.is_ok()),
}
}
#[test]
fn save_queue_submits_the_trimmed_name_and_esc_cancels() {
let (mut app, rx) = app();

View File

@ -322,10 +322,10 @@ impl Downloader {
return write_skipped(track, dir, index).await;
};
if !(url.starts_with("http://") || url.starts_with("https://")) {
// Local file playables (fs tracks, existing captures) resolve
// to paths, not URLs — nothing to download.
warn!(path = track_path, "recording non-http playable as skipped");
return write_skipped(track, dir, index).await;
// A local file playable (an fs track, or a re-captured
// capture): the audio is already on disk, so copy it into the
// capture instead of skipping it.
return self.copy_local(track, url, dir, index, bytes_left).await;
}
let download_err = |err: reqwest::Error| {
CaptureError::Download(format!("{track_path}: {}", err.without_url()))
@ -430,6 +430,50 @@ impl Downloader {
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?;
Ok(TrackOutcome::Captured)
}
/// Copies an already-local audio file (an fs playable, or a track
/// from an existing capture) into the capture folder next to its
/// toml. A source that is missing or not a regular file is recorded
/// as skipped rather than aborting the run; the copy counts against
/// the same byte budget as a download so a capture cannot run away.
async fn copy_local(
&self,
track: &Track,
source: &str,
dir: &Path,
index: usize,
bytes_left: &mut u64,
) -> Result<TrackOutcome, CaptureError> {
let track_path = track.path.as_str();
let source_path = Path::new(source);
match tokio::fs::metadata(source_path).await {
Ok(meta) if meta.is_file() => {
let len = meta.len();
if len > *bytes_left {
return Err(CaptureError::TooLarge("download budget exhausted"));
}
let ext = source_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin");
let audio_name = audio_file_name(index, &track.title, ext);
tokio::fs::copy(source_path, dir.join(&audio_name)).await?;
*bytes_left -= len;
let text = fsdy::TrackFile::from_track_with_file(track, Path::new(&audio_name))
.to_toml()?;
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text)
.await?;
Ok(TrackOutcome::Captured)
}
_ => {
warn!(
path = track_path,
"local playable is not a readable file; recording as skipped"
);
write_skipped(track, dir, index).await
}
}
}
}
/// Parses `bytes <start>-<end>/<total|*>` into `(start, end, total)`.

View File

@ -570,10 +570,11 @@ mod tests {
}
#[tokio::test]
async fn uncapturable_tracks_are_recorded_as_skipped() {
async fn capturing_a_mixed_queue_downloads_web_and_copies_local() {
// A queue-like source: a downloadable fsdy instance whose folder
// mixes an http track with a local-file track — like a persisted
// queue linking tidal and fs entries.
// mixes an http track with a local-file track — exactly a
// persisted queue linking tidal and fs entries. Both must end up
// with local audio: the web one downloaded, the fs one copied.
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
let src = TempDir::new().expect("source tempdir");
let mix = src.path().join("mix");
@ -597,10 +598,9 @@ mod tests {
store
.capture(&source, "/queues/mix", "mixed", &silent())
.await
.expect("capture succeeds despite the local track");
// The http track downloaded; the local-file track was recorded as
// a skipped toml at its listing position — no silent omission
// (architecture/incremental-captures.md D2).
.expect("capture");
// Both tracks captured with audio next to their toml: the web
// track downloaded, the local track copied in.
let root = store.dir().join("mixed");
assert_eq!(
visible(&root),
@ -608,13 +608,16 @@ mod tests {
"0001 web.cbd-track.toml".to_string(),
"0001 web.flac".into(),
"0002 local.cbd-track.toml".into(),
"0002 local.flac".into(),
]
);
assert_eq!(
playable_of(&root, "0002 local.cbd-track.toml"),
fsdy::Playable::Skipped
);
// The skipped entry lists as a skipped wire track.
match playable_of(&root, "0002 local.cbd-track.toml") {
fsdy::Playable::File(rel) => {
assert_eq!(fs::read(root.join(&rel)).expect("copied audio"), b"local");
}
other => panic!("expected the local track copied, got {other:?}"),
}
// Neither lists as skipped.
let captures = fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf())
.expect("captures instance");
let node = captures
@ -622,8 +625,7 @@ mod tests {
.await
.expect("node");
assert_eq!(node.tracks.len(), 2);
assert!(!node.tracks[0].is_skipped);
assert!(node.tracks[1].is_skipped);
assert!(node.tracks.iter().all(|t| !t.is_skipped));
}
#[tokio::test]
@ -742,4 +744,40 @@ mod tests {
assert!(last.download);
assert_eq!(last.name, "faves");
}
#[tokio::test]
async fn local_file_playables_are_copied_not_skipped() {
// An fs-style playable: the provider resolves the track to a
// local file path (not an http URL). The capture must copy that
// audio in, not record the track as skipped.
let src = TempDir::new().expect("source dir");
let audio = src.path().join("song.flac");
fs::write(&audio, b"flacdata").expect("write source audio");
let mock = MockProvider::new(&[("/mock/a/1", audio.to_str().unwrap())], true);
let (store, dir) = store().await;
store
.capture(&mock, "/mock/a", "faves", &silent())
.await
.expect("capture");
let capture_dir = dir.path().join("captures").join("faves");
// Track one ("one"): a File playable whose audio was copied in
// next to the toml, with the source extension and contents.
let one_toml = fsdy::track_file_name(0, "one");
match playable_of(&capture_dir, &one_toml) {
fsdy::Playable::File(rel) => {
let copied = capture_dir.join(&rel);
assert!(copied.exists(), "audio copied next to the toml");
assert_eq!(fs::read(&copied).expect("read copy"), b"flacdata");
assert_eq!(rel.extension().and_then(|e| e.to_str()), Some("flac"));
}
other => panic!("expected a copied File playable, got {other:?}"),
}
// Track two ("two") has no source and is still recorded skipped.
let two_toml = fsdy::track_file_name(1, "two");
assert!(matches!(
playable_of(&capture_dir, &two_toml),
fsdy::Playable::Skipped
));
}
}

View File

@ -775,3 +775,32 @@ tests green; clippy (native + wasm) and fmt clean.
audio output device, unavailable in this headless environment. The
components are unit-tested and the wiring compiles and starts; the live
path should be sanity-checked on a machine with audio.
## capture-local-files + queue-W (2026-07-22)
Two capture fixes (the first two of a set; store relocation and a
central dedup store come as a later refactor).
- **Capturing local playables (#4, `capture.rs`)**: a download capture
used to record any non-http(s) playable as *skipped* — so capturing
an fs node or a queue mixing streamed and local tracks produced red,
audioless entries even though the audio was on disk. `fetch_track`
now routes a local-file source to a new `copy_local`, which copies the
file in next to its toml (source extension preserved, counted against
the run's byte budget); a missing/unreadable source still records
skipped. Rewrote the mixed-queue test to assert copy-not-skip and
added a direct local-copy test.
- **`W` on the queue (#3, `cbd-tui`)**: the queue pane bound only `w`
(save); capturing the queue meant save-then-navigate-then-`W`. Added
`QueueDownloadCapture` on Shift-`W` in the queue scope, which
download-captures `/queues/current` (the continuously persisted live
queue) via the usual name dialog. Bound + dispatch + tests.
Docs: architecture/incremental-captures.md D2 updated (local files
copied, not skipped); root README capture section updated (queue `W`,
local-copy behavior).
Deferred to the refactor: moving queues/bookmarks/captures out of
`.config` into `.local/state` (with migration), and a central
content-addressed audio store so captures dedup and link instead of
copy.