From ef69afdd5ff84c39b0a5580166b1e44a7e89724c Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 22 Jul 2026 01:08:29 +0200 Subject: [PATCH] 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) --- README.md | 13 ++++-- architecture/incremental-captures.md | 15 ++++--- cbd-tui/src/app/bindings.rs | 17 ++++++- cbd-tui/src/app/mod.rs | 57 ++++++++++++++++++++++++ crabidy-server/src/capture.rs | 52 ++++++++++++++++++++-- crabidy-server/src/capture_store.rs | 66 ++++++++++++++++++++++------ plan/summary.md | 29 ++++++++++++ 7 files changed, 220 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 2375898..17a8368 100644 --- a/README.md +++ b/README.md @@ -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/` 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. diff --git a/architecture/incremental-captures.md b/architecture/incremental-captures.md index 05db3ff..a0bdc6a 100644 --- a/architecture/incremental-captures.md +++ b/architecture/incremental-captures.md @@ -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 diff --git a/cbd-tui/src/app/bindings.rs b/cbd-tui/src/app/bindings.rs index 328620f..65f2b6b 100644 --- a/cbd-tui/src/app/bindings.rs +++ b/cbd-tui/src/app/bindings.rs @@ -99,6 +99,11 @@ pub enum Action { /// Open the input overlay asking for a name to save the queue under /// (persisted as `/queues/`). No-op while the queue is empty. QueueSaveAs, + /// Open the input overlay to **download-capture** the current queue + /// straight into `/captures/` (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) ); } diff --git a/cbd-tui/src/app/mod.rs b/cbd-tui/src/app/mod.rs index 4bee8d5..8227d20 100644 --- a/cbd-tui/src/app/mod.rs +++ b/cbd-tui/src/app/mod.rs @@ -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(); diff --git a/crabidy-server/src/capture.rs b/crabidy-server/src/capture.rs index dea177d..b4cbb80 100644 --- a/crabidy-server/src/capture.rs +++ b/crabidy-server/src/capture.rs @@ -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 { + 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 -/` into `(start, end, total)`. diff --git a/crabidy-server/src/capture_store.rs b/crabidy-server/src/capture_store.rs index 66da6cc..f62691f 100644 --- a/crabidy-server/src/capture_store.rs +++ b/crabidy-server/src/capture_store.rs @@ -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 + )); + } } diff --git a/plan/summary.md b/plan/summary.md index e227fd1..8b35e12 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -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.