Non-interactive mode rework: tracklist input and live progress reporting UI #32

Merged
maleszka merged 18 commits from feature/non-interactive into main 2026-07-13 16:59:45 +02:00
Owner
No description provided.
Adds resolve_job_queue/resolve_lines: classifies each input line via
classify_input, expands tracklists, dedupes by track ID, aborts on the
first unresolvable line, and rejects an empty result. Not yet wired
into script::run.
script::run now builds a single-entry DownloadQueue and drives it via
DownloadSession::spawn_pending/handle_msg, so partials.json is loaded
and updated like every other session instead of being stubbed out.
The fixed 1s ticker task is replaced by a per-WorkerMsg logger
throttled to ~1 line/sec per entry.

WorkerMsg::Failed now carries the originating AppError's exit_code
(captured before it's stringified), and a new AppError::DownloadFailed
variant lets script::run surface that code after the failure has
passed through the shared queue machinery.
script::run now takes an InputSource and resolves it through
resolve_job_queue, so a tracklist URL (or, once InputSource::Stdin is
reachable, several stdin lines) expands to its full track list instead
of being rejected. All resolved tracks are pushed into one queue via
push_back_many and driven through the same concurrency-bound worker
pool; a per-track path error is reported and skipped rather than
aborting the run, unless every track fails, which is a hard error.
Already-Skipped tracks are reported up front, before any download
starts.
Extracts the download-driving loop into run_job_queue, which races a
shutdown future (real Ctrl+C in production) against every WorkerMsg.
On shutdown: no further entry is spawned, every Downloading entry is
cancelled via cancel_entry (never cancel_all, so handle_msg's per-entry
cleanup isn't duplicated), and the loop drains until each cancelled
entry reaches Paused, persisting its partial exactly like any other
transition. The interruption itself is reported as a failure so the
process still exits non-zero even if no track hit a real error.
The mechanism already fell out of run_job_queue's design (spawn_pending
keeps pulling the next Pending entry regardless of an earlier Failed,
and failure.get_or_insert_with keeps only the first one observed) —
this adds a MixedOutcomeProvider-backed test proving it directly: a
permanently-failing track doesn't stop a concurrently-downloading one,
and its real SpotifyError exit code wins over a later Ctrl+C
interruption's generic code, matching Decision 8's first-failure-wins
contract.
Cli::is_interactive() now requires both no positional URL and an
interactive stdin, so a bare invocation with piped/redirected stdin
enters non-interactive mode instead of the search UI. cli::run's
dispatch calls is_interactive() instead of re-deriving the same
condition independently, and now routes a URL-less, non-TTY-stdin
invocation into script::run with InputSource::Stdin.
Decision 11 selected the "windowed rows" layout but never specified how
rows fit a given terminal width, and the research spike behind it used a
one-shot, never-revisited column formula with no narrow-terminal floor.
Adds Decision 16: reuse progress.rs's compute_row_layout/placeholder_line
width-tiering verbatim (poll-based, no resize-event dependency, no
resize_overshoot equivalent needed since frame height is a constant), and
render placeholder text per-row below MIN_TERMINAL_COLS instead of
collapsing the frame, to preserve the constant 1+concurrency line count.
Implements the "windowed rows" layout (specs/features/non-interactive.md
Decision 11): a constant 1 + concurrency frame, one persistent row per
concurrency slot, redrawn in place. Entries stay in their slot until they
reach a terminal state, at which point a permanent scrollback line is
printed once and the slot refills from the remaining queue order.

Extracts per-row field derivation out of ProgressRenderer::render into a
shared compute_row_fields helper, and promotes progress.rs's width-tiering
primitives (compute_row_layout, placeholder_line, MIN_TERMINAL_COLS,
COMPACT_LAYOUT_COLS, queue_dotline_bar, fmt_time, trunc_chars) to
pub(crate), so both renderers agree on what a state looks like and how a
row fits a given terminal width from one source of truth (Decision 16).
Not yet wired into script.rs — lands in the next commit.
Adds ProgressDisplay, an enum wrapping either the Stage 1 plain
event-driven logger or the new CompactProgress renderer, selected by
io::stdout().is_terminal() (Decision 9) independent of the stdin-gated
interactive/non-interactive mode trigger. run_job_queue drives whichever
one is active: Plain logs from each WorkerMsg before the transition
lands, Compact redraws from the queue's post-transition state and also
on a periodic tick (gated on next_tick_deadline) so its smoothed
speed/position stays live between message arrivals. Already-Skipped
tracks are now reported through the same abstraction so CompactProgress
gets its report_skipped call per the draft contract.

Also fixes main.rs's missing-credentials error message, which still
claimed a track URL was required to reach the non-interactive path even
though a piped/redirected stdin with no URL now reaches it too.

Removes CompactProgress's temporary #[allow(dead_code)] now that it's
wired into production code.
Adds tests/integration/non_interactive.rs with two kinds of coverage:

- A no-network cross-session PartialStore resume test, driven through
  the real DownloadSession/handle_msg engine rather than hand-rolled
  persistence calls: a track paused by one session (standing in for
  either mode, since handle_msg's Paused arm has no session-type
  parameter) resumes correctly in a freshly constructed second session.
  Proves the "in both directions" claim in Decision 5 with one round
  trip, since nothing in this path can vary by direction.
- Real subprocess end-to-end tests against a live Spotify session
  (credentials.json-gated, matching the existing suite's convention):
  a stdin-piped 3-track job at concurrency=2 completes all tracks, its
  --json summary reports the correct aggregate count (previously only
  exercised with one entry), and a best-effort poll of .raw.part files
  confirms concurrency is never exceeded; and a malformed stdin line
  aborts the whole run (exit 1) before any download starts (Decision 13).

The failing-middle-track scenario from the feature spec's Test
Specifications is already covered by Stage 1's mock-based unit test
(run_job_queue_continues_past_a_failed_track_and_keeps_the_first_failures_exit_code
in cli/script.rs) — the spec itself marks that scenario "(mocked...)",
and a real Spotify track can't be made to fail on demand, so no new
subprocess-level test was added for it.
maleszka deleted branch feature/non-interactive 2026-07-13 16:59:46 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
maleszka/librespot-dl!32
No description provided.