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:
parent
80c4b6e7ed
commit
ef69afdd5f
13
README.md
13
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/<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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue