9.2 KiB
Incremental captures, skipped tracks, and capture progress
Context and problem statement
Download captures (W, architecture/captures.md) are all-or-nothing: the
whole subtree is built in a hidden temp folder and swapped into place; any
failure destroys everything downloaded so far. For a large node that means
hours of downloading can evaporate on one bad track, and re-running restarts
from zero. Mixed-provider sources (queues, bookmarks) silently omit
uncapturable tracks, so the capture's track list quietly diverges from the
source. And while a capture runs, the user sees nothing — worse, the TUI's
poll loop awaits the capture RPC, so the client is effectively frozen until
the capture finishes.
This design makes download captures incremental and resumable, records uncapturable tracks as a first-class skipped playable, streams capture progress to clients, and warns about long captures up front. A small, unrelated fix rides along: colored library items (editable/creatable/marked) are unreadable under the focused selection bar (D7).
Assumptions
- "Capture" here means the download capture (
W). Bookmark captures (w) stay atomic tmp-and-swap: they are cheap, and "overwrite = refresh" is the right semantic for links. They do adopt the skipped playable for skipped source tracks (D1) and report progress (D5). - Resuming keys on the name: capturing into an existing capture name
merges into that folder. Entry identity is the deterministic toml file name
(
NNNN <title>.cbd-track.toml), so resuming assumes the source keeps its order — appending to a queue is fine, reordering it re-captures under new names and leaves stale files behind (the user can delete the capture and start over). Accepted. - One capture per name at a time is the user's responsibility (same as the old racing-tmp behavior); concurrent same-name captures interleave per file, last writer wins. Accepted.
D1 — A skipped playable
Track files get a fourth playable: [playable] skipped = true, validated
with the same exactly-one cardinality as file/url/link
(skipped = false counts as unset and is rejected). Semantics: this
position in the tree is a real track whose audio could not be captured.
fsdy::Playable::Skipped;TrackFile::from_track_skipped(track)builds one from a wire track.- Wire:
Track.is_skipped(proto field 6).TrackFile::to_tracksets it; the track'spathstays the lib path (likefile), there is nothing to route to. TrackFile::from_track(queue persistence, bookmarks) writes a skipped playable when the source trackis_skipped— skipped-ness survives queue persistence and bookmark round trips instead of degrading into a dead link.get_urls_for_trackon a skipped file returnsProviderError::FetchError(playback never asks, see D4; direct callers get a normal typed error).
Alternative considered: model skipped-ness as absence (keep omitting the track) plus a client-side diff against the source. Rejected — the source may be gone tomorrow; the capture itself must record the gap.
D2 — Incremental download captures
capture_into splits into two phases:
- Enumerate: the existing iterative pre-order walk collects every
directory and track (with its listing index) first, enforcing
max_dirs/max_tracks. This makes the total known before the first download — progress can be a real ratio — and costs only metadata calls. - Fetch: process the collected tracks in order, feeding progress after each one.
The sink decides the write mode:
Sink::Link(bookmarks): unchanged tmp-and-swap into.tmp-<name>, all-or-nothing.Sink::Download(captures): writes directly intodir/<name>/, creating directories as needed, never deleting existing content. Per track, in order:- The target toml exists, parses, and its playable is not skipped, and
(for a
fileplayable) the referenced audio exists → reuse (counts as done, no download). Aurl/linkplayable 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. - 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 missing entries only.
- The target toml exists, parses, and its playable is not skipped, and
(for a
Audio is still written before its toml, so a crash mid-download leaves a toml-less audio file that the resume simply re-downloads (truncating on create). The byte budget counts only bytes downloaded this run, so resuming a large capture is never starved by what is already on disk.
direction: right
walk: capture_into {
enumerate: "phase 1: enumerate\n(dirs + tracks, caps)"
fetch: "phase 2: fetch\n(per track, in order)"
enumerate -> fetch: "total known"
}
walk.fetch -> reuse: "toml ok + audio present"
walk.fetch -> skipped: "source uncapturable\n→ skipped = true toml"
walk.fetch -> download: "download + toml"
walk.fetch -> abort: "download failure\n(keeps progress)"
D3 — Skipped tracks in the queue
Skipped tracks queue like any other (the user sees the gap instead of a silently shorter queue). The TUI renders them red; playback skips them.
Playback::play already loops past tracks whose URLs fail to resolve. It now
additionally:
- skips
is_skippedtracks without a provider round trip, and - bounds the whole skip loop by the queue length at entry — an all-skipped queue with repeat on used to be an infinite provider-hammering spin; now it stops the player with a warning after one full pass.
D4 — Capture progress on the update stream
New stream update (proto):
message CaptureProgress {
string name = 1; // capture / bookmark name
bool download = 2; // W capture vs w bookmark
uint32 tracks_done = 3; // settled: reused + downloaded + linked +
// skipped — reaches tracks_total on success
uint32 tracks_total = 4; // known after enumeration (0 until then)
uint32 tracks_skipped = 5; // of those, skipped tomls written this run
bool finished = 6;
string error = 7; // set iff finished with a failure
}
CaptureLibraryNode now returns once the capture is accepted: the
provider validates the name, the store, and the source's download blessing,
replies, and runs the walk on its spawned task, streaming CaptureProgress
through a bounded channel that the RPC layer forwards into the existing
update broadcast. Completion and failure arrive as the final progress event
(finished, error), not as the RPC result.
Rationale: the TUI's orchestration loop select!s over one RPC at a time —
a capture RPC that lasts an hour freezes every other interaction. Validation
errors still come back synchronously with the old status mapping; walk
errors move to the stream (and the server log, as before).
D5 — TUI: progress, red skipped tracks, warnings
- Skipped tracks are red (and not bold) in both the queue and library
listings, driven by
Track.is_skipped. The playing-track red keeps precedence in the queue. - Progress lines render at the bottom of the library pane, one per
active capture:
capturing <name> 12/34 (2 skipped)(bookmarks:bookmarking). A finished capture lingers ~5 s ascaptured <name>: 34 tracks (2 skipped); a failed one shows the error in red for ~10 s. State lives in theApp, fed by the update stream; the 100 ms render tick handles expiry. - Warnings: the
Whelp-table description and the capture input overlay's label both say a download capture can take a long time (and that re-capturing the same name resumes it).
D6 — Out of scope
- Cancelling a running capture from the TUI.
- Retrying real download failures within a run (rerun-to-resume covers it).
- Garbage-collecting stale entries when the source shrank or reordered.
- Multi-hop link resolution for skipped detection (a link whose target is a skipped file plays as a normal link failure).
D7 — Focused-selection contrast fix
Library items styled with a foreground color (creatable/editable/deletable →
secondary, marked → green; queue: skipped/current → red) are hard to read
when the focused selection bar (bg = COLOR_PRIMARY, a light blue) sits on
them. Fix: when an item is the selected row of a focused pane, its
foreground switches to the dark COLOR_PRIMARY_DARK so it reads against the
light bar. The unfocused bar is dark and keeps the colored foregrounds.
Risks
- Enumerate-then-fetch holds the full entry list in memory: bounded by
max_tracks(500 download / 20 000 bookmark) — fine. - A source whose listing order changes between runs duplicates content under new prefixes (assumption above). Accepted; documented in the help text via the "resumes by name" phrasing.
- The progress channel is bounded (64); a slow broadcast consumer only slows the walk, never blocks it permanently (the forwarder drains continuously).