199 lines
9.6 KiB
Markdown
199 lines
9.6 KiB
Markdown
# 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_track` sets it;
|
|
the track's `path` stays the lib path (like `file`), there is nothing to
|
|
route to.
|
|
- `TrackFile::from_track` (queue persistence, bookmarks) writes a skipped
|
|
playable when the source track `is_skipped` — skipped-ness survives queue
|
|
persistence and bookmark round trips instead of degrading into a dead
|
|
link.
|
|
- `get_urls_for_track` on a skipped file returns `ProviderError::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:
|
|
|
|
1. **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.
|
|
2. **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** into `dir/<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 `file` playable) the referenced audio exists → **reuse** (counts
|
|
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 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
|
|
missing entries only.
|
|
|
|
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.
|
|
|
|
```d2
|
|
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_skipped` tracks **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):
|
|
|
|
```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 as
|
|
`captured <name>: 34 tracks (2 skipped)`; a failed one shows the error in
|
|
red for ~10 s. State lives in the `App`, fed by the update stream; the
|
|
100 ms render tick handles expiry.
|
|
- **Warnings**: the `W` help-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).
|