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.
|
provider to replay) or, in the queue pane, saves the queue.
|
||||||
- `W` **captures** the selection: the subtree is mirrored under
|
- `W` **captures** the selection: the subtree is mirrored under
|
||||||
`/captures/<name>` with every track's audio downloaded next to its
|
`/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
|
re-capturing the same name resumes and completes it; tracks whose
|
||||||
source cannot be captured are recorded as *skipped* (red in the UI,
|
source genuinely cannot be captured are recorded as *skipped* (red in
|
||||||
skipped by playback). Download captures can take long; progress is
|
the UI, skipped by playback). Download captures can take long;
|
||||||
shown in the library pane. Inside `/captures`, `d` deletes any
|
progress is shown in the library pane. Inside `/captures`, `d` deletes
|
||||||
|
any
|
||||||
folder or single track *from disk* (audio included) after a `y/N`
|
folder or single track *from disk* (audio included) after a `y/N`
|
||||||
confirmation.
|
confirmation.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,11 +80,16 @@ The sink decides the write mode:
|
||||||
as done, no download). A `url`/`link` playable also counts as satisfied
|
as done, no download). A `url`/`link` playable also counts as satisfied
|
||||||
— only this store writes here, but hand-edited files should not be
|
— only this store writes here, but hand-edited files should not be
|
||||||
clobbered.
|
clobbered.
|
||||||
- Otherwise the track is (re)captured: a source that cannot be captured —
|
- Otherwise the track is (re)captured. A source that resolves to a
|
||||||
the track is itself skipped, its stream fails to resolve, resolves to
|
local **file** path rather than an http(s) URL — an fs playable, or a
|
||||||
nothing, or to a non-http(s) target — writes a **skipped toml**
|
track from an existing capture — is **copied** into the capture next
|
||||||
(`from_track_skipped`) and counts as skipped. This replaces the old
|
to its toml (counting against the same byte budget as a download), so
|
||||||
silent omission.
|
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)
|
- A real download failure (HTTP status, transport, timeout, byte budget)
|
||||||
**aborts the run but keeps everything written so far** — re-running the
|
**aborts the run but keeps everything written so far** — re-running the
|
||||||
same name resumes exactly where it stopped, re-attempting skipped and
|
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
|
/// Open the input overlay asking for a name to save the queue under
|
||||||
/// (persisted as `/queues/<name>`). No-op while the queue is empty.
|
/// (persisted as `/queues/<name>`). No-op while the queue is empty.
|
||||||
QueueSaveAs,
|
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
|
// Help modal
|
||||||
CloseHelp,
|
CloseHelp,
|
||||||
}
|
}
|
||||||
|
|
@ -425,6 +430,13 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
action: Action::QueueSaveAs,
|
action: Action::QueueSaveAs,
|
||||||
description: "Save queue under a name",
|
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 {
|
Binding {
|
||||||
scope: Scope::Queue,
|
scope: Scope::Queue,
|
||||||
mods: KeyModifiers::NONE,
|
mods: KeyModifiers::NONE,
|
||||||
|
|
@ -595,7 +607,8 @@ mod tests {
|
||||||
),
|
),
|
||||||
Some(Action::LibraryCaptureNode)
|
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!(
|
assert_eq!(
|
||||||
lookup(
|
lookup(
|
||||||
UiFocus::Library,
|
UiFocus::Library,
|
||||||
|
|
@ -610,7 +623,7 @@ mod tests {
|
||||||
false,
|
false,
|
||||||
key(KeyCode::Char('W'), KeyModifiers::SHIFT)
|
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_ORANGE: Color = Color::Rgb(208, 135, 112);
|
||||||
// const COLOR_BRIGHT: Color = Color::Rgb(216, 222, 233);
|
// 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
|
// FIXME: Rename this
|
||||||
pub enum MessageToUi {
|
pub enum MessageToUi {
|
||||||
Init(InitialData),
|
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
|
DispatchResult::Continue
|
||||||
}
|
}
|
||||||
|
|
@ -1426,6 +1445,44 @@ mod tests {
|
||||||
assert_eq!(input.buffer, "");
|
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]
|
#[test]
|
||||||
fn save_queue_submits_the_trimmed_name_and_esc_cancels() {
|
fn save_queue_submits_the_trimmed_name_and_esc_cancels() {
|
||||||
let (mut app, rx) = app();
|
let (mut app, rx) = app();
|
||||||
|
|
|
||||||
|
|
@ -322,10 +322,10 @@ impl Downloader {
|
||||||
return write_skipped(track, dir, index).await;
|
return write_skipped(track, dir, index).await;
|
||||||
};
|
};
|
||||||
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||||
// Local file playables (fs tracks, existing captures) resolve
|
// A local file playable (an fs track, or a re-captured
|
||||||
// to paths, not URLs — nothing to download.
|
// capture): the audio is already on disk, so copy it into the
|
||||||
warn!(path = track_path, "recording non-http playable as skipped");
|
// capture instead of skipping it.
|
||||||
return write_skipped(track, dir, index).await;
|
return self.copy_local(track, url, dir, index, bytes_left).await;
|
||||||
}
|
}
|
||||||
let download_err = |err: reqwest::Error| {
|
let download_err = |err: reqwest::Error| {
|
||||||
CaptureError::Download(format!("{track_path}: {}", err.without_url()))
|
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?;
|
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?;
|
||||||
Ok(TrackOutcome::Captured)
|
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)`.
|
/// Parses `bytes <start>-<end>/<total|*>` into `(start, end, total)`.
|
||||||
|
|
|
||||||
|
|
@ -570,10 +570,11 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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
|
// A queue-like source: a downloadable fsdy instance whose folder
|
||||||
// mixes an http track with a local-file track — like a persisted
|
// mixes an http track with a local-file track — exactly a
|
||||||
// queue linking tidal and fs entries.
|
// 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 url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
|
||||||
let src = TempDir::new().expect("source tempdir");
|
let src = TempDir::new().expect("source tempdir");
|
||||||
let mix = src.path().join("mix");
|
let mix = src.path().join("mix");
|
||||||
|
|
@ -597,10 +598,9 @@ mod tests {
|
||||||
store
|
store
|
||||||
.capture(&source, "/queues/mix", "mixed", &silent())
|
.capture(&source, "/queues/mix", "mixed", &silent())
|
||||||
.await
|
.await
|
||||||
.expect("capture succeeds despite the local track");
|
.expect("capture");
|
||||||
// The http track downloaded; the local-file track was recorded as
|
// Both tracks captured with audio next to their toml: the web
|
||||||
// a skipped toml at its listing position — no silent omission
|
// track downloaded, the local track copied in.
|
||||||
// (architecture/incremental-captures.md D2).
|
|
||||||
let root = store.dir().join("mixed");
|
let root = store.dir().join("mixed");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
visible(&root),
|
visible(&root),
|
||||||
|
|
@ -608,13 +608,16 @@ mod tests {
|
||||||
"0001 web.cbd-track.toml".to_string(),
|
"0001 web.cbd-track.toml".to_string(),
|
||||||
"0001 web.flac".into(),
|
"0001 web.flac".into(),
|
||||||
"0002 local.cbd-track.toml".into(),
|
"0002 local.cbd-track.toml".into(),
|
||||||
|
"0002 local.flac".into(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
match playable_of(&root, "0002 local.cbd-track.toml") {
|
||||||
playable_of(&root, "0002 local.cbd-track.toml"),
|
fsdy::Playable::File(rel) => {
|
||||||
fsdy::Playable::Skipped
|
assert_eq!(fs::read(root.join(&rel)).expect("copied audio"), b"local");
|
||||||
);
|
}
|
||||||
// The skipped entry lists as a skipped wire track.
|
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())
|
let captures = fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf())
|
||||||
.expect("captures instance");
|
.expect("captures instance");
|
||||||
let node = captures
|
let node = captures
|
||||||
|
|
@ -622,8 +625,7 @@ mod tests {
|
||||||
.await
|
.await
|
||||||
.expect("node");
|
.expect("node");
|
||||||
assert_eq!(node.tracks.len(), 2);
|
assert_eq!(node.tracks.len(), 2);
|
||||||
assert!(!node.tracks[0].is_skipped);
|
assert!(node.tracks.iter().all(|t| !t.is_skipped));
|
||||||
assert!(node.tracks[1].is_skipped);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -742,4 +744,40 @@ mod tests {
|
||||||
assert!(last.download);
|
assert!(last.download);
|
||||||
assert_eq!(last.name, "faves");
|
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
|
audio output device, unavailable in this headless environment. The
|
||||||
components are unit-tested and the wiring compiles and starts; the live
|
components are unit-tested and the wiring compiles and starts; the live
|
||||||
path should be sanity-checked on a machine with audio.
|
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