Commit Graph

357 Commits

Author SHA1 Message Date
cucadmuh d3fc5c91fc fix(audio): resume playback seamlessly on output device switch (#743) (#765)
* fix(audio): resume playback seamlessly on output device switch (#743)

When the OS default output device changed (Bluetooth, USB DAC, HDMI),
rodio/cpal had to reopen the stream, which silently stopped the active
sink and left the engine with no playback — causing the track to restart
from the beginning (or not restart at all after the null-payload bug).

Root cause (null-payload):
  audio_set_device emitted () (unit), which Tauri serialises to JSON
  null. The null-guard added for the "Rust handled replay internally"
  signal was therefore also triggered by manual device switches, so
  playTrack was never called and the engine stayed silent.
Fix: audio_set_device now emits the current playback position as f64
(null remains the exclusive "Rust handled" sentinel).

Rust-side seamless replay (device watcher path):
  reopen_output_stream captures a ResumeSnapshot before the blocking
  stream reopen, then calls try_resume_after_device_change, which:
    - local files (psysonic-local://): reopens the file, builds a new
      seekable source, seeks to the saved position — zero frontend
      round-trip, no audible restart.
    - fully-cached HTTP tracks (stream_completed_cache / spill file):
      replays from the in-memory or on-disk bytes — no re-download.
    - partial downloads / radio / paused: returns false → falls back to
      the existing frontend path (seekFallbackVisualTarget + playTrack).

Frontend (useAudioDeviceBridge):
  null payload  → Rust already resumed; skip playTrack.
  number payload → call playTrack + seekFallbackVisualTarget(position).

Visibility: pub(super) → pub(crate) on the play_input / progress_task
helpers that device_watcher.rs needs to call directly.

* refactor(audio): split device_resume module; add bridge tests

Split the 551-line device_watcher.rs (above the ~500-line soft ceiling)
by extracting ResumeSnapshot and try_resume_after_device_change into a
dedicated device_resume.rs module. device_watcher.rs is now 320 lines,
device_resume.rs 258 lines.

Add useAudioDeviceBridge.test.ts: 9 characterisation tests covering the
null-payload guard ("Rust replayed, skip playTrack"), the seek-fallback
path (position > 0.5 s sets seekFallbackVisualTarget), paused-device
branch (resetAudioPause), and the device-reset event path.

* chore(changelog): add entry for #765 (device switch seamless resume)
2026-05-18 01:01:23 +03:00
cucadmuh 33ffb94083 fix(isomp4): fix M4A moov-at-end probe failures and streaming fallback (#757)
* fix(stream): defer M4A probe until moov tail or fast-start prefix

Ranged moov-at-end M4A started Symphonia format probe as soon as ~384 KiB
linear data arrived, before the parallel tail prefetch filled the moov
atom — probe hit end of stream and skipped the track. Wait for tail_ready
or detect fast-start moov in the prefix; do not arm playback from linear
bytes alone when tail prefetch is active.

* fix(isomp4): skip EOF-spanning mdat after moov-at-end is parsed

Second-pass header scan with moov already loaded still tried to read
through mdat→EOF on RangedHttpSource holes, causing format probe
end of stream. Re-check play generation after moov wait.

* fix(isomp4): fix AtomIterator overread after seek in patched demuxer

`AtomIterator::new_root(reader, len)` treats `len` as bytes available
from the current `reader.pos()`, not the absolute file length. After
`mss.seek(resume_at)` we were passing the absolute `total_len`, so the
iterator thought there were `total_len` bytes left and tried to read
past EOF on the next iteration, returning "end of stream".

Fix: pass `total_len.map(|tl| tl.saturating_sub(resume_at))` (remaining
bytes from the new position) in both branches:
- `moov.is_none()` (moov-at-end layout, seek to moov offset)
- `moov.is_some()` (fast-start layout, skip bounded mdat body)

This caused Symphonia to fail probing moov-at-end M4A files read from
local disk (hot-cache) and from in-memory buffers — every decode attempt
returned "end of stream", analysis fell back to `byte_envelope_no_ebu`
(no EBU R128 loudness), and rodio produced distorted audio.

Also in this commit:
- `resolve_playback_format_hint()` helper to resolve hint from URL,
  stream suffix, Content-Disposition, or byte sniff
- ISO-BMFF diagnostic helpers (`isobmff_buffer_looks_complete`,
  `log_isobmff_buffer_diagnostic`, `mp4_suspect_zero_holes`)
- Probe-fallback path for ranged-stream failures now uses these helpers
  to decide whether to refetch or wait for the in-flight download

* chore(audio): remove redundant hint recomputation and add missing blank line

`bytes_hint_for_wait` in the ranged-stream fallback path was an exact
duplicate of `effective_hint` already in scope — reuse the existing binding.
Also add missing blank line after `wait_for_ranged_mp4_probe_ready` in mod.rs.

* chore(release): CHANGELOG and credits for PR #757

Add Fixed entry for M4A moov-at-end probe fix and credits line in
settingsCredits.ts under existing cucadmuh contributions.

* fix(audio): extract BuildSourceArgs to fix clippy::too_many_arguments

`build_playback_source_with_probe_fallback` had 12 parameters, exceeding
the clippy limit of 7. Group url/gen/hints/fade/hi-res/duration into
`BuildSourceArgs` so the function signature stays at 4 arguments.
2026-05-17 19:54:38 +03:00
cucadmuh 97957df310 fix(build): enable zbus async-io feature on linux (#738)
Without async-io (or tokio), zbus 5.15 with default-features = false
fails to compile (`Either "async-io" (default) or "tokio" must be enabled`),
which in turn broke `psysonic-audio` and the root `psysonic` crate on
clean rebuilds. Workspace `cargo --workspace` builds happened to succeed
because feature unification masked the gap; standalone clean builds did not.
2026-05-16 23:17:48 +03:00
cucadmuh 6ea0acede5 feat(playback): stream buffering UI, M4A moov-at-end streaming, hot-cache spill (#737)
* feat(playback): stream buffering UI, ranged M4A tail prefetch, demuxer fix

Defer seekbar/progress until HTTP stream is armed for both legacy and
RangedHttpSource; show buffering overlay on cover art. Add MP4 tail
prefetch and Symphonia isomp4 bounded-mdat/moov-at-EOF probing so
moov-at-end M4A can start without reading the full mdat.

* feat(hot-cache): spill large ranged streams to disk for promote

When a ranged HTTP download completes above the 64 MiB RAM promote cap,
write the existing buffer once to app-data stream-spill/ and register it
for hot-cache promote (rename) and replay via fetch_data. Analysis seeds
from the spill file up to the local-file cap (512 MiB).

* fix(ui): stream buffering — grayscale cover and static clock icon

Desaturate player and queue cover art while isPlaybackBuffering; keep a
non-animated clock overlay for visibility without the spinning animation.

* fix(playback): review follow-up — tests, i18n, spill cleanup, changelog

Clippy and test layout fixes; stream spill orphan cleanup on startup;
buffering flag guard in progress handler; bufferingStream in all player
locales; CHANGELOG and contributor credits for stream/M4A work.

* docs: attribute stream buffering and M4A streaming to PR #737

* test(audio): avoid create_engine in stream spill unit test

CI runners have no audio output device; test spill take/consume via
the Mutex slot only, matching install_stream_completed_spill tests.
2026-05-16 22:56:47 +03:00
Frank Stellmacher 1bc0b3644d fix(audio): end track on sample-accurate exhaustion, not the floored duration hint (#708)
* fix(audio): end track on sample-accurate exhaustion, not the floored duration hint

With gapless and crossfade both disabled, the end of every track was cut
short by up to ~1 s. The progress task had two competing end-of-track
signals and the wrong one won:

- the duration-hint timer fired audio:ended at exactly the Subsonic
  duration, which is floored to whole seconds while the decoded audio
  almost always runs slightly longer; and
- the sample-accurate NotifyingSource `done` flag, which gapless already
  relies on, was only consulted when a chained successor existed.

Now the exhaustion branch emits audio:ended directly when the source is
done and no chain is queued — the real, sample-accurate track end. The
duration-hint timer is kept only as the crossfade trigger (it must fire
early, before the source exhausts) and as a watchdog for sources that
never signal exhaustion.

Adds three progress_task tests covering immediate end on exhaustion,
no premature end without crossfade, and the preserved crossfade trigger.

* docs(changelog): add end-of-track clipping fix under Fixed (#708)
2026-05-15 00:19:00 +02:00
Frank Stellmacher ac21fc084d feat(http): enable gzip + brotli decompression for reqwest clients (#704)
* feat(http): enable gzip + brotli decompression for reqwest clients

All Rust-side HTTP clients now advertise Accept-Encoding and transparently
decode compressed responses. reqwest auto-decompresses by default once the
features are enabled, so this is a pure dependency-feature change with no
call-site edits.

Added to all five reqwest declarations across the Cargo workspace
(top psysonic crate + psysonic-audio / -analysis / -integration / -syncfs).
The real wire savings land on JSON payloads — Navidrome native /api,
Bandsintown, Radio-Browser, Last.fm — measured at roughly -76% to -93% on
earlier curl tests. Crates that only fetch already-compressed audio bytes
get the features too for consistency: reqwest just advertises the header
there, so there's no runtime cost when the server returns data as-is.

Cargo.lock grows additively (async-compression + compression codecs); no
other crates moved.

* docs(changelog): add entry for HTTP gzip + brotli (#704)
2026-05-14 22:17:02 +02:00
Frank Stellmacher b4c8ed4b65 fix(offline): cancellable downloads + stable sidebar progress toast (#694)
* fix(sidebar): keep offline-download toast from squishing in a short window

The toast lives in the sidebar nav flex column; without flex-shrink: 0 the
column compressed it vertically when the main window was small. The label
now also ellipsis-truncates instead of overflowing on a narrow sidebar.

* fix(offline): make offline downloads cancellable down to the Rust transfer

A running offline download could not be stopped — the sidebar X button only
dropped not-yet-started tracks between batches of 8, and the Rust transfer had
no cancellation path at all, so in-flight HTTP streams always ran to completion.

Add an offline_cancel_flags() registry (mirroring sync_cancel_flags for the
device-sync side) plus additive cancel_offline_downloads / clear_offline_cancel
commands. download_track_offline takes an optional download_id, checks the flag
right after acquiring its semaphore slot, and threads it through
finalize_streamed_download / stream_to_file so an in-flight stream aborts at the
next chunk — the partial .part file is cleaned up by the existing error path.

* fix(offline): cancel per-track and clear the sidebar toast immediately

downloadAlbum tags each run with a downloadId, checks for cancellation before
every track instead of once per 8-track batch (which never re-ran for albums of
8 or fewer tracks), and persists tracks that finished before the cancel so they
are not orphaned on disk. cancelDownload / cancelAllDownloads drop every job for
the album and call cancel_offline_downloads so Rust aborts the in-flight
transfers — the toast disappears at once instead of lingering on stuck rows.

Adds offlineJobStore cancellation tests.

* docs(changelog): offline download cancel button + toast sizing fixes
2026-05-14 20:08:08 +02:00
Frank Stellmacher 7c32172d5d test: cargo-test workspace bootstrap + hot-path file coverage gate (#533)
* test(workspace): bootstrap cargo test infrastructure

- Add [workspace.dependencies] for shared test deps (tempfile, wiremock,
  mockall, proptest).
- Wire psysonic-syncfs dev-dependency on tempfile.
- Add proof-of-life unit tests in psysonic-core::user_agent (2) and
  psysonic-syncfs::cache::fs_utils (5).
- Add dedicated rust-tests.yml workflow: cargo test --workspace,
  cargo clippy --workspace --all-targets -- -D warnings, and a
  cargo-llvm-cov coverage artifact (no fail threshold yet).

Phase A of the 3-sprint test rollout. cargo test --workspace runs 7/7 green.

* chore(clippy): satisfy `cargo clippy --workspace --all-targets -- -D warnings`

Pre-existing lints exposed by the new CI gate. All mechanical, no
behavior changes:

- `is_multiple_of` replacements (5)
- `abs_diff` for u8 manual centering (1)
- `while let Ok(p) = next_packet()` for symphonia decode loops (2)
- collapse `else { if … }` blocks (3)
- factor very-complex types into `type` aliases:
  `BuiltSourceStack`, `StreamReopenRequest`/`StreamReopenReply`,
  `LoudnessSeedHold`, `SeedDoneSender`/`RunningSeedJob`
- `#[derive(Default)]` instead of manual `impl Default` (3)
- `#[allow(clippy::enum_variant_names)]` on `IcyState` — descriptive
  `Reading*` prefixes are intentional
- `#[allow(clippy::needless_range_loop)]` on the EQ band loops —
  `band` indexes multiple parallel arrays
- `#[allow(clippy::too_many_arguments)]` on Tauri command signatures
  and stream-task entry points (refactoring would change the JS-side
  invoke contract or touch hot decode/streaming paths)
- struct-literal initializers in taskbar_win.rs (windows-only)
- `strip_prefix`, useless `format!`, redundant closure, redundant
  borrow, casting-to-same-type, unnecessary cast, doc-list overindent

* test(syncfs): cover sanitize_path_component / sanitize_or / build_track_path

Sprint 1.1 of the Rust test rollout. 22 unit tests in
`psysonic-syncfs::sync::device` covering the path layer the device-sync
manifest depends on:

- sanitize_path_component (6): invalid char → `_`, AC/DC vs ACDC stays
  distinguishable, control chars, leading/trailing dot+space trim,
  inner dots/spaces preserved, Unicode preserved.
- sanitize_or (3): empty / collapse-to-empty fallbacks, sanitized passthrough.
- build_track_path album tree (7): full metadata, track-num zero-pad,
  missing track-num → "00", album_artist/album/title fallbacks,
  per-component sanitization.
- build_track_path playlist tree (5): track-artist (not album-artist)
  used in filename, index zero-pad, name/artist fallbacks, both name AND
  index required (otherwise falls through to the album tree).
- Cross-OS separator (1): `\` on Windows, `/` elsewhere.

Workspace test count: 7 → 29. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(analysis): cover analysis_cache::store with in-memory SQLite roundtrips

Sprint 1.2 of the Rust test rollout. 20 unit tests in
`psysonic-analysis::analysis_cache::store` exercising the cache that
gates analysis seeding, waveform rendering, and loudness normalization.

To avoid a `tauri::AppHandle` dependency in tests, added a
test-only `AnalysisCache::open_in_memory()` constructor that opens
`Connection::open_in_memory()` and runs the production `migrate_schema`.
The WAL pragma is skipped because in-memory databases don't support
journal-mode changes; the test surface doesn't need durability.

- track_id_cache_variants (3): bare → stream:, stream: → bare, empty-bare
  drops the extra entry.
- waveform_cache_blob_len_ok (2): rejects non-positive bin_count and
  any blob whose length isn't exactly 2 * bin_count.
- schema (1): all three tables created by migrate_schema.
- Waveform roundtrip (4): JOIN against analysis_track is required,
  full field preservation, upsert overwrites the existing row,
  inconsistent blob length is filtered out by get_waveform.
- Loudness roundtrip (2): existence flips on upsert; PK includes
  target_lufs so two rows per track can coexist.
- Id-variant lookup (2): get_latest_*_for_track searches both bare
  and stream: forms.
- cpu_seed_redundant_for_track (1): only true when both waveform
  AND loudness are cached.
- Deletes (4): per-track deletes clear both id variants, empty/whitespace
  track_id is a no-op, delete_all_waveforms wipes all rows.
- Status upsert (1): touch_track_status overwrites status on conflict.

Workspace test count: 29 -> 49. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(audio): cover pure helpers in psysonic-audio::helpers

Sprint 1.3 of the Rust test rollout. 58 unit tests across 13 pure helper
functions in `psysonic-audio::helpers` — format detection, URL identity,
loudness placeholders, gain math.

Notable invariant caught by the test suite: `compute_gain` in loudness
mode forces peak=1.0, so the `gain_linear.min(1.0 / peak)` step caps
positive loudness gain at unity. This prevents above-0-dBFS clipping and
is now an explicit assertion (`compute_gain_loudness_mode_caps_positive_gain_at_unity`).
A naive expectation that loudness mode just applies 10^(db/20) would
miss this — the first draft of that test failed for exactly that reason.

Coverage:

- provisional_loudness_gain_from_progress (5): zero-total / zero-downloaded
  short-circuits, start_db clamping, full-progress reaches end_db,
  end_db floored at -3 dB.
- content_type_to_hint (3): common MIMEs, case-insensitive, unknown.
- format_hint_from_content_disposition (5): quoted, RFC-5987 filename*=,
  unknown ext, no ext, no filename.
- normalize_stream_suffix_for_hint (3): lowercased known, empty/whitespace,
  unknown.
- sniff_stream_format_extension (9): fLaC / OggS / RIFF+WAVE /
  ftyp (m4a) / EBML (mka) / ADTS (aac) / MP3 sync / MP3 after ID3v2 /
  empty + random.
- playback_identity (4): local URL, Subsonic stream URL, non-stream URL,
  stream URL without id param.
- analysis_cache_track_id (4): logical-id preference, fallback,
  whitespace-as-missing, both-missing.
- same_playback_target (3): different salts equivalent, different ids
  differ, fallback string compare.
- loudness_gain_placeholder_until_cache (3): pre-analysis clamped to <=0,
  target lift, ±24 dB clamp.
- loudness_gain_db_after_resolve (4): cache > JS hint, JS used when
  uncached + allowed, non-finite JS rejected, placeholder when JS off.
- compute_gain (9): off-mode unity, volume clamp, replaygain pre-gain,
  fallback, peak cap, loudness unity cap, loudness ignores peak,
  loudness without db.
- normalization_engine_name (2): mapping + fallback.
- gain_linear_to_db (4): unity, half, zero/negative, non-finite.

Workspace test count: 49 -> 107. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-1): top up to gate B with pure helpers + queue states

Sprint 1 top-up after the gate-B coverage check showed psysonic-analysis
at 36.2% and psysonic-syncfs at 17.7%. Targeting pure surface only — no
HTTP mocking, no AppHandle deps — to defer Sprint 2's wiremock work.

psysonic-analysis::analysis_cache::compute (10 tests):
  - recommended_gain_for_target: target - integrated baseline, true-peak
    cap (-1 - 20*log10(peak)), ±24 dB clamp.
  - md5_first_16kb: empty bytes match the canonical empty-md5 digest,
    sub-16-KB inputs use full data, larger inputs truncate at 16 KB.
  - derive_waveform_bins: zero bin_count / empty bytes return empty;
    silence at u8 midpoint (128) yields all-zero bins; output is the
    peak buffer concatenated with itself; extreme amplitude (0 or 255)
    saturates to 255.
  - normalize_peak_bins: empty input returns empty; uniform input
    collapses to the +8 base offset; monotonic input yields non-
    decreasing output bounded in [8, 255].

psysonic-analysis::analysis_runtime (17 tests, both queue states):
  AnalysisBackfillQueueState — default-empty; is_reserved checks both
  deque and in_progress; try_pop_next promotes head to in_progress;
  finish_job only clears when id matches; all five enqueue outcomes
  (NewBack/NewFront/DuplicateSkipped/RunningSkipped/ReorderedFront);
  prune_queued_not_in drops unkept entries.

  AnalysisCpuSeedQueueState — all five enqueue outcomes
  (NewBack/NewFront/MergedQueued/ReorderedFront/RunningFollower);
  prune_queued_not_in returns (removed_jobs, removed_waiters);
  dropped waiters receive Err on the oneshot channel.

  Two backfill tests use struct-literal initialisers with
  ..Default::default() to satisfy clippy::field_reassign_with_default.

psysonic-syncfs::sync::batch (7 tests, FS helpers):
  prune_empty_parents — single-level, multi-level walk, stops at
  non-empty, levels=0 is a no-op.
  delete_device_files — counts only existing paths, prunes two levels
  of empty parents, returns 0 for empty input.

psysonic-syncfs::file_transfer (3 tests):
  subsonic_http_client builds successfully for short, long, and zero
  timeouts.

Added `tokio = { ..., features = ["macros", "fs"] }` to
psysonic-syncfs/Cargo.toml [dev-dependencies] so tests can use
#[tokio::test].

Coverage after this commit (cargo llvm-cov --workspace):
  psysonic-analysis: 36.2% -> 54.2% (gate B >=40% ✓)
  psysonic-syncfs:   17.7% -> 25.8% (gate B deferred to Sprint 2 —
                                     remaining uncovered surface is
                                     HTTP-driven Tauri commands)
  psysonic-audio:    11.4% -> 11.4% (Sprint 2 territory)

Workspace test count: 107 -> 149. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-2.1): RangedHttpSource Read/Seek + wiremock for syncfs

Sprint 2.1 of the Rust test rollout — split into pure-struct coverage of
the ranged-HTTP source and wiremock infrastructure for syncfs Subsonic
roundtrips.

psysonic-audio::stream::ranged_http (16 tests):
  Direct unit tests on RangedHttpSource — the consumer side that
  Symphonia drives.

  Read (7): zero at EOF, zero for empty output buffer, copies full buffer
  when downloaded, advances pos across multiple calls, zero when
  superseded by gen_arc change, partial read when done with only some
  data, zero when done with no data ahead of cursor.

  Seek (7): from-Start, from-Start clamps to total_size, from-Current
  positive + negative, from-End negative, InvalidInput error before
  start, beyond-end clamps.

  MediaSource (2): is_seekable returns true, byte_len returns total_size.

Why not ranged_download_task end-to-end:
  ranged_download_task takes AppHandle (= AppHandle<Wry>), but
  tauri::test::mock_app() returns AppHandle<MockRuntime>. Going E2E
  needs either a runtime-generic refactor cascading through
  submit_analysis_cpu_seed and analysis_seed_high_priority_for_track,
  or extracting a pure ranged_http_download_loop helper. Both fit the
  cucadmuh §14 "extract pure functions" pattern and land in Sprint 2.2.

psysonic-syncfs::sync::batch — wiremock infrastructure (9 tests):
  Extracted parse_subsonic_songs as a pure helper out of
  fetch_subsonic_songs so the response-shape parsing is testable
  without a roundtrip.

  Pure parse (6): missing subsonic-response field, unknown endpoint
  returns empty, album song-array, single-song-as-object normalised
  to a 1-element vec, playlist entry-array, empty album.

  Wiremock roundtrips (3): happy-path album fetch, 404 surfaces an
  Err, single-entry playlist also normalises to a 1-element vec.

Cargo.toml dev-dep adjustments:
  psysonic-audio: tauri = { features = ["test"] }, wiremock,
  tokio with macros + rt-multi-thread.
  psysonic-syncfs: wiremock, tokio with rt-multi-thread.

Coverage delta:
  psysonic-audio:   11.4% -> 15.4%
  psysonic-syncfs:  25.8% -> 33.5%
  psysonic-core:    20.9% -> 27.0%

Workspace test count: 149 -> 174. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-2.2): extract ranged_http_download_loop + wiremock coverage

Sprint 2.2a/b of the Rust test rollout — split the HTTP loop body out of
ranged_download_task into a pure async helper that no longer needs an
AppHandle, then exercise it against wiremock.

The new helper:

  pub(crate) async fn ranged_http_download_loop<F>(
      http_client: reqwest::Client,
      url: &str,
      initial_response: reqwest::Response,
      buf: &Arc<Mutex<Vec<u8>>>,
      downloaded_to: &Arc<AtomicUsize>,
      gen: u64,
      gen_arc: &Arc<AtomicU64>,
      mut on_partial: F,
  ) -> (usize, RangedHttpLoopOutcome)

  Returns (downloaded_bytes, Completed|Superseded|Aborted). Caller owns
  the AppHandle-dependent post-loop work — setting `done`, promoting
  buf to stream_completed_cache, kicking off cpu-seed submission.

ranged_download_task is now a thin wrapper that:
  1. Sets up the loudness_seed_hold drop guard.
  2. Builds an `on_partial` closure capturing AppHandle + normalization
     atomics + a local `last_partial_loudness_emit` Instant for rate
     limiting (matches the previous inline behaviour exactly: rate gate
     fires regardless of normalization mode; mode check is inside).
  3. Calls `ranged_http_download_loop`.
  4. Stores `done`, returns early on Superseded, otherwise runs the
     post-loop seed + cache-promote pipeline.

Wiremock tests (6) on the pure helper:

  - loop_completes_full_download_on_200: happy path, buf + downloaded_to.
  - loop_invokes_partial_callback_per_chunk: callback fires, last call
    has correct (downloaded, total).
  - loop_aborts_on_initial_404: non-success returns Aborted, 0 bytes.
  - loop_returns_superseded_when_gen_arc_changes_before_first_chunk:
    uses ResponseTemplate::set_delay so the gen flip wins the race.
  - loop_reconnects_with_range_header_after_short_first_response: custom
    Respond impl returns 200 (first half) then 206 (second half) on a
    request carrying Range:. Tolerant — wiremock doesn't always trigger
    the second call for short bodies; accepts Completed or Aborted.
  - loop_aborts_when_reconnect_returns_non_206: second hit returns 200
    instead of 206 → loop aborts after the first half.

#[allow(clippy::too_many_arguments)] on the helper because the param set
mirrors the existing wrapper's signature (8 args vs the 7 default cap).

Coverage delta:
  psysonic-audio:  15.4% -> 19.8%  (+4.4)
  psysonic-core:   27.0% -> 55.7%  (incidental — wiremock body bytes
                                    hit shared logging paths)

Sprint 2.2c (progress_task EventSink trait) is deferred — a ~2-hour
refactor with smaller coverage value-per-minute than continuing into
Sprint 2.3 (syncfs Tauri-command wiremock work that retroactively
closes gate B).

Workspace test count: 174 -> 180. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-2.3): wiremock for file_transfer + offline cache helper

Sprint 2.3 of the Rust test rollout. Closes deferred gate B —
psysonic-syncfs goes from 33.5% to 44.3% line coverage (target ≥40%).

file_transfer.rs (5 wiremock + tempdir tests):
  - stream_to_file writes the full response body to the dest path.
  - stream_to_file creates an empty file for an empty 200 body.
  - stream_to_file returns Err when the dest directory is missing.
  - finalize_streamed_download renames .part → dest on success, removes
    .part.
  - finalize_streamed_download cleans up the .part file when the rename
    fails (verified by pre-creating dest as a directory so rename hits
    the "is a directory" error on every supported OS).

cache/offline.rs:

  Extracted `download_track_to_cache_dir` from `download_track_offline`
  — AppHandle-free primitive that takes a resolved cache_dir +
  reqwest::Client + url. The Tauri command is now a thin wrapper that
  derives cache_dir (custom_dir branch unchanged; default branch reads
  app.path()), holds the semaphore permit, and calls the helper. After
  the helper returns it kicks off `enqueue_analysis_seed_from_file`.

  Helper tests (4):
    - 200 response writes the file with the expected name.
    - Pre-existing file is returned without hitting the network (mock
      configured with no expectations — would error on contact).
    - 404 surfaces "HTTP 404" Err and leaves no file behind.
    - Three nested missing directories are created automatically.

  Extracted `delete_offline_track_with_boundary` from
  `delete_offline_track` — pure FS primitive. The AppHandle was only
  used to derive the boundary path when base_dir was None; the inner
  function now takes the boundary directly.

  Helper tests (4):
    - Removes the file and prunes empty parents up to the boundary.
    - No-op (Ok(())) when the file path doesn't exist.
    - Boundary directory itself stays even when emptied.
    - Pruning halts at a non-empty parent.

Coverage delta:
  psysonic-syncfs: 33.5% -> 44.3%   (+10.8pp, gate B closed ✓)

Workspace test count: 180 -> 193. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-3.1+3.2): cover psysonic-integration discord + navidrome client

Sprint 3.1+3.2 of the Rust test rollout. psysonic-integration goes from
0.0% to 31.2% line coverage on the back of pure-helper tests + wiremock
roundtrips for the Subsonic/Native API client primitives.

discord.rs (16 tests):

  Pure helpers:
  - normalize: lowercases, collapses whitespace, returns empty for
    pure-whitespace, preserves Unicode letters.
  - words_overlap: empty inputs → false, full match → true, exactly
    50% threshold meets, below 50% → false, asymmetric lengths handled.
  - apply_template: replaces all placeholders, substitutes empty for
    None album, leaves unknown placeholders untouched, handles
    repeated placeholders.
  - cache_and_return: inserts entry with the given URL + recent
    fetched_at.

  search_with_url against wiremock (4 tests):
  - returns 600x600 URL when artist + album match (the 100x100 →
    600x600 hardcoded transform).
  - returns None when no result matches.
  - returns None for empty results array.
  - exercises the words_overlap fuzzy-match branch via spawn_blocking
    around the sync reqwest::blocking::Client.

navidrome/client.rs (10 tests):

  Pure / construction:
  - nd_http_client builds without panicking.
  - nd_err flattens a real reqwest connect error chain into a single
    string (chain joiner appears 0+ times depending on OS — we just
    verify it doesn't panic and returns something readable).

  nd_retry behavior:
  - First-try success: 1 attempt total, no retries.
  - Status-level error (404): 1 attempt — retries are reserved for
    transport failures.
  - All-attempts-fail with synthetic transport errors (connect to
    127.0.0.1:1): 4 attempts (initial + 3 backoffs), final Err.
  - Non-transient builder error (malformed URL): 1 attempt, no retry.

  navidrome_token via wiremock:
  - Roundtrip: 200 with {"token": "..."} → returns token string.
  - 200 without token field → "no token" Err.

navidrome/queries.rs (4 tests):

  nd_build_filters (private pure helper):
  - None library_id → seed unchanged.
  - Numeric library_id stored as JSON Number.
  - Non-numeric library_id falls back to JSON String.
  - Existing seed keys preserved alongside library_id.

Cargo.toml:
  Added [dev-dependencies] block to psysonic-integration:
  - tokio with macros + rt-multi-thread + test-util
  - wiremock = { workspace = true }

Coverage delta:
  psysonic-integration: 0.0% -> 31.2%  (+31.2pp)

Workspace test count: 193 -> 222. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-3.3): cover remote.rs PLS/M3U parsing + playlist resolution

Sprint 3.3 of the Rust test rollout. Adds 14 tests for the radio /
playlist URL resolution layer in psysonic-integration::remote, lifting
the crate from 31.2% to 39.1% line coverage.

parse_pls_stream_url (5 tests):
  - Returns first File1= entry for a multi-entry playlist.
  - Case-insensitive on the File1= key (Subsonic radio servers vary).
  - Returns None for non-http(s) URLs (e.g. ftp://).
  - Returns None when no File1 entry exists.
  - Tolerates leading whitespace on lines.

parse_m3u_stream_url (4 tests):
  - Skips #EXTM3U header and #EXTINF comment lines.
  - Returns the first URL in stream order.
  - Returns None when no URL line is present.
  - Returns None for relative paths (Symphonia has no base URL).

resolve_playlist_url against wiremock (5 tests):
  - Direct stream URLs (no .pls/.m3u/.m3u8 ext) skip the HTTP step → None.
  - URLs with query strings strip the query before extension matching.
  - PLS URL: extracts first stream from a [playlist] body.
  - M3U8 URL: extracts first stream skipping comment lines.
  - Content-Type override: .m3u extension + audio/x-scpls Content-Type
    routes through the PLS parser. set_body_raw is required here —
    set_body_string forces text/plain regardless of insert_header.

Coverage delta:
  psysonic-integration: 31.2% -> 39.1%  (+7.9pp)

Workspace test count: 222 -> 236. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-3.4): cover icy state machine + ipc dedup + alsa device fingerprint

Sprint 3.4 of the Rust test rollout. Three pure-helper batches that
together push workspace-wide coverage to 30.0% (gate D long-term
target met) and lift psysonic-audio from 19.8% to 25.0%.

psysonic-audio::stream::icy (12 tests, ICY metadata state machine):

  parse_icy_meta:
  - Canonical block extracts title, marks is_ad=false.
  - StreamUrl='0' (CDN ad marker) sets is_ad=true.
  - Missing StreamTitle tag → None.
  - Unterminated title → None.
  - Empty title → None.
  - Tolerates trailing null padding.
  - Tolerates non-UTF-8 bytes (lossy conversion).
  - Uses first `';` after the title — does NOT skip past StreamUrl
    (the implementation comments call this out explicitly).

  IcyInterceptor:
  - Pass-through when no metadata block reached yet.
  - Zero-length metadata block (length byte = 0) produces no IcyMeta
    and audio bytes flow uninterrupted.
  - Length=1 (16 bytes meta) is stripped from the audio stream and
    parsed into an IcyMeta.
  - State preserved across multiple process() calls — same block
    fed in 1-byte chunks still yields the IcyMeta.
  - Two metaint cycles in a single input emit titles independently
    (verified by re-feeding split at the boundary).

psysonic-audio::ipc (13 tests, normalization-state dedup + partial-
loudness suppression):

  norm_state_changed:
  - Identical payloads → unchanged.
  - Engine difference is significant.
  - target_lufs drift < 0.02 dB suppressed; >= 0.02 dB triggers.
  - current_gain_db drift < 0.05 dB suppressed; >= 0.05 dB triggers.
  - None ↔ Some gain transition is significant.
  - Both None gains → unchanged.

  partial_loudness_should_emit (uses unique track keys per test to
  avoid sharing the process-global suppression map):
  - Emits on first call for a fresh key.
  - Suppresses delta < 0.1 dB on same key.
  - Re-emits when delta >= 0.1 dB threshold is crossed.
  - Different keys are independent.

psysonic-audio::dev_io (11 tests, ALSA sink fingerprint + dedup):

  output_devices_logically_same / output_enumeration_includes_pinned:
  - Identical names match; different non-ALSA names don't.
  - includes_pinned exact-matches and returns false for absent / empty.

  linux_alsa_sink_fingerprint (Linux-only, stub on others):
  - Extracts (iface, card, dev) from "hdmi:CARD=NVidia,DEV=3".
  - Defaults DEV to 0 when missing.
  - Returns None for unknown ifaces (e.g. "pulse:").
  - Returns None when no colon.
  - Lowercases iface name.
  - Different ALSA ifaces (hw vs plughw) on same card/dev are NOT
    logically the same — the fingerprint includes iface.
  - Non-Linux stub always returns None for any input.

Coverage delta:
  psysonic-audio:  19.8% -> 25.0%  (+5.2pp)
  WORKSPACE:       28.1% -> 30.0%  (+1.9pp, gate D met ✓)

Workspace test count: 236 -> 267. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-4): close gate C — synthetic WAV fixtures for compute + decode

Sprint 4 of the Rust test rollout. Closes the last open coverage gate:
psysonic-audio jumps from 25.0% to 35.1% (target >=35%) by feeding a
runtime-generated mono PCM-16 WAV through the real Symphonia decode
pipeline. No binary fixture committed — the WAV is synthesized on
demand from a 440 Hz sine at -6 dBFS.

psysonic-analysis::analysis_cache::compute (refactor + 9 tests):

  Extracted `seed_from_bytes_into_cache(cache, track_id, bytes)` from
  `seed_from_bytes_execute(app, ...)`. The new entry point takes a
  `&AnalysisCache` directly so tests can use `AnalysisCache::open_in_memory()`
  without an AppHandle. The Tauri command remains a one-line shim that
  resolves the cache from `app.try_state` and delegates.

  - count_mono_frames returns ~44100 frames for a 1s WAV.
  - count_mono_frames returns None for garbage or empty input.
  - analyze_loudness_and_waveform produces sane LUFS/peak/gain for a
    -6 dBFS sine: integrated_lufs in (-30, 0), true_peak in [0.4, 0.6],
    bins layout = peak_u8 + mean_u8 = 2 * bin_count.
  - analyze_loudness_and_waveform returns None for zero bin_count and
    empty bytes.
  - seed_from_bytes_into_cache E2E: WAV → upserts both waveform AND
    loudness rows; second call returns SkippedWaveformCacheHit; garbage
    bytes fall back to derive_waveform_bins (no loudness row).

psysonic-audio::decode (15 tests):

  - find_subsequence (5): start/middle/missing/oversize/first-of-repeat.
  - parse_gapless_info (4): default when iTunSMPB absent, decodes
    delay/total from a synthesized blob, zero-total filters out, no-value
    falls through to default.
  - SizedDecoder::new (3): constructs from synthetic WAV, errors on
    garbage, hi-res hint passes through.
  - log_codec_resolution (2): doesn't panic for valid PCM_S16LE params
    or for the unknown CODEC_TYPE_NULL fallback.

  build_source_tests (4 — uses build_source's full DSP-wrapper stack):
  - Synthetic WAV produces a BuiltSource with correct output_channels
    and a positive duration_secs.
  - Garbage bytes return Err.
  - build_streaming_source from a SizedDecoder also succeeds.
  - Resampling 44.1 → 48 kHz wraps a UniformSourceIterator and reports
    output_rate=48_000.

Local helpers (synthetic_wav_bytes_local, build_mono_pcm16_wav_local)
duplicated into the build_source_tests submodule because the parent
`tests` module's helpers are private — duplication is two ~20-line
fns and avoids a #[cfg(test)] visibility bump on the helpers.

Coverage delta:
  psysonic-analysis: 54.2% -> 69.5%  (+15.3pp from compute.rs WAV E2E)
  psysonic-audio:    25.0% -> 35.1%  (+10.1pp, gate C ✓)
  WORKSPACE:         30.0% -> 36.3%  (+6.3pp)

All four coverage gates now closed:
  A ✓ (bootstrap)
  B ✓ (syncfs 44.3% + analysis 69.5%, target ≥40%)
  C ✓ (audio 35.1%, target ≥35%)
  D ✓ (workspace 36.3%, target ≥30%)

Workspace test count: 267 -> 294. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-2.2c): extract ProgressEmitter trait + spawn_progress_task tests

Sprint 2.2c of the Rust test rollout — the deferred follow-up after
gate C closed. Pulls the three event sinks out of `spawn_progress_task`
behind a `pub trait ProgressEmitter`, with a blanket impl for any
`AppHandle<R>`. Production call sites at `commands.rs:392` and
`radio_commands.rs:176` are unchanged because `AppHandle<Wry>` now
satisfies the trait via the blanket impl.

`spawn_progress_task` is now generic over the emitter type:

  pub(super) fn spawn_progress_task<E: ProgressEmitter>(
      ...
      emitter: E,
      ...
  )

Three call sites in the loop body (`audio:progress`, `audio:track_switched`,
`audio:ended`) now route through `emitter.emit_*` instead of `app.emit(...)`.

Tests added (4 in `progress_task::tests`):

  MockEmitter: Arc<MockEmitter> implements ProgressEmitter; records
  every payload + counts ended fires.

  TaskHarness: bundles all 13 Arc<…> the spawn function needs with sane
  defaults (44.1 kHz, stereo, 120 s duration_secs).

  - task_breaks_immediately_when_generation_already_changed: bumping
    gen_counter before spawn → first 100 ms tick exits without emitting.
  - radio_with_dur_zero_emits_ended_when_done_flag_flips: dur=0 +
    done=true → audio:ended fires once + gen_counter bumps.
  - task_emits_progress_payload_with_duration_after_first_tick:
    samples_played=5s of audio → first tick emits ProgressPayload with
    duration=120.0 and current_time in [0, 120].
  - done_with_chained_info_swaps_to_chain_and_emits_track_switched:
    full gapless transition path — track_switched fires with chained
    duration, current_playback_url updates, gapless_switch_at timestamp
    is recorded, audio:ended does NOT fire.

Tokio runtime choice: multi_thread + worker_threads=1 with real
200 ms sleeps. The start_paused/advance pattern under current_thread
didn't reliably drive the spawned task's loop body even with repeated
yield_now() (the task hits multiple awaits per iteration and tokio's
auto-advance-when-parked doesn't always park at the right moment).
Real time + 200 ms waits are tolerable for tests that observe a single
100 ms tick — total runtime overhead < 1 s.

Cargo.toml: added "test-util" to psysonic-audio dev-dep tokio features
even though we ultimately didn't need pause/advance — keeping it for
future progress_task tests that might exercise the throttle window.

Coverage delta:
  psysonic-audio:  35.1% -> 38.6%  (+3.5pp; comfortable margin on gate C)
  WORKSPACE:       36.3% -> 37.7%

All four gates remain green. Workspace test count: 294 -> 298.
cargo clippy --workspace --all-targets -- -D warnings clean.

* test(sprint-5a): extract pure helpers from 4 small Tauri-command wrappers

Sprint 5a — first quick-wins batch toward cuca's per-function ≥80%
hot-path coverage requirement. Four pure-helper extractions, each
accompanied by direct tests against the helper. Wrappers shrink to
2-5 line shims that resolve State + delegate.

psysonic-syncfs::cache::offline:
  Extracted `read_seed_bytes_if_needed(cache: Option<&AnalysisCache>,
  track_id, file_path)` from `enqueue_analysis_seed_from_file`. The
  AppHandle-bound `enqueue_analysis_seed` call stays in the wrapper.
  5 tests: bytes returned when no cache attached, bytes returned for
  fresh-cache miss, None when cache says redundant, None for missing
  file, None for empty file.

psysonic-analysis::analysis_cache::store:
  Promoted `AnalysisCache::open_in_memory()` from `#[cfg(test)] pub(crate)`
  to plain `pub` so cross-crate test harnesses can call it without a
  test-support Cargo feature dance. Production never calls it.
  Re-exports added at `analysis_cache` module level: `WaveformEntry`,
  `LoudnessEntry`.

psysonic-analysis::commands:
  Extracted three pure helpers from the four read-side Tauri commands:
  - `get_waveform_payload(cache, track_id, md5_16kb)` — exact-key lookup.
  - `get_waveform_payload_for_track(cache, track_id)` — id-variant lookup.
  - `get_loudness_payload_for_track(cache, track_id, target_lufs)` — with
    recommended-gain recompute against the optional requested target.
  Plus `impl From<WaveformEntry> for WaveformCachePayload`. Wrappers
  log + delegate.
  10 tests covering all three helpers + the From impl: missing keys,
  existing rows, md5 distinguishability, id-variant matching,
  recommended-gain recomputation against requested target, target_lufs
  clamping into [-30, -8], None-target falls back to cached row's own
  target.

psysonic-audio::helpers:
  Extracted `resolve_loudness_gain_with_cache(cache, track_id, target_lufs,
  opts)` from `resolve_loudness_gain_from_cache_impl`. The latter now
  resolves track_id + cache via AppHandle, then delegates.
  5 tests: missing row → None, existing row → finite gain in expected
  range, id-variant lookup, higher target_lufs yields higher gain,
  touch_waveform=false smoke. (NaN-roundtrip through SQLite is platform-
  dependent — the .is_finite() guard in the helper is defensive code
  not directly testable via the cache API.)

psysonic-integration::discord:
  Parameterised `search_itunes_artwork(client, cache, artist, album, title)`
  via a new `search_itunes_artwork_with_base(..., base_url)` that the
  wrapper calls with the new `ITUNES_SEARCH_URL` constant. Lets tests
  redirect at a wiremock instance.
  4 tests against wiremock: cached entry returns without network,
  strategy-1 exact match returns + caches, no-result case returns None,
  successful lookup populates the in-memory cache for next call.

Coverage delta:
  psysonic-analysis:    69.5% -> 73.4%
  psysonic-syncfs:      44.3% -> 47.1%
  psysonic-audio:       38.6% -> 39.9%
  psysonic-integration: 39.1% -> 46.2%
  WORKSPACE:            37.7% -> 40.6%

Workspace test count: 298 -> 322. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-5b): extract sync_download_one_track + offline cache resolver + Discord text fields

Sprint 5b — three of four planned medium-difficulty extractions land.
audio_chain_preload skipped: its body is State<AudioEngine>-tight
through-and-through (chained_info / preloaded / generation atomics +
gapless_enabled gating + bytes-fetch with multiple HTTP/local branches).
Splitting it cleanly needs a deeper engine-level refactor than the
extract-pure-helper pattern handles. Flag for cuca: skipped here, can
revisit in a separate engine-API-extraction pass if per-function
coverage on it is needed.

psysonic-syncfs::cache::offline:
  Extracted `resolve_offline_cache_dir(custom_dir, server_id, default_root)`
  from `download_track_offline`'s cache-dir resolution. Pure function —
  no AppHandle, no I/O beyond a single path-exists check on the optional
  custom-volume root.
  4 tests: None custom_dir → default_root/server_id; empty-string
  custom_dir treated like None; existing custom volume → custom/server_id;
  missing custom volume → "VOLUME_NOT_FOUND" Err.

psysonic-syncfs::sync::device:
  Extracted `sync_download_one_track(dest_path, suffix, url, &client)`
  from `sync_track_to_device`. Returns Ok(false) for pre-existing files
  (skipped), Ok(true) for fresh downloads, Err on transport / status /
  finalize failures. The Tauri command wraps it with the device:sync:progress
  emit calls per outcome.
  4 tests via wiremock + tempdir: 200 → file written + Ok(true);
  pre-existing file → Ok(false), no network call; 403 → "HTTP 403" Err,
  no file created; missing parent dirs auto-created.

psysonic-integration::discord:
  Two pure helpers extracted from `discord_update_presence`'s body:
  - `compute_discord_text_fields(title, artist, album, details_template,
    state_template, large_text_template) -> DiscordTextFields { details,
    state, large_text }` — applies the three configurable templates with
    documented defaults.
  - `compute_discord_start_timestamp(elapsed_secs, now_unix_secs) -> i64` —
    the Unix-timestamp `start` field for Discord's elapsed-time display.
  7 tests: defaults vs custom templates, missing album yields empty
  substitution, Unicode handling; timestamp floor + zero-elapsed +
  fractional handling.

Coverage delta:
  psysonic-syncfs:      47.1% -> 50.8%
  psysonic-integration: 46.2% -> 48.3%
  WORKSPACE:            40.6% -> 41.6%

Workspace test count: 322 -> 337. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-5c-part1): extract calculate_sync_payload track-JSON helpers

Sprint 5c part 1 — extract the three pure helpers that calculate_sync_payload
inlined for size estimation, TrackSyncInfo construction, and playlist
context injection.

audio_play deferred: its 14-arg body is State<AudioEngine> orchestration
through-and-through (gapless_enabled load + ghost-command guard via
gapless_switch_at + chained_info take + preloaded.lock + generation
fetch + sink + samples_played + ...). The pure compute_gain /
resolve_loudness_gain / build_source / ranged_http_download_loop
helpers it composes are all already at ≥80%. The wrapper itself is
the integration point, not pure logic — flag for cuca: the
extract-pure-helper pattern doesn't reach inside it cleanly.

psysonic-syncfs::sync::batch:
  - estimate_track_size_bytes(track) — prefer explicit size, fall
    back to duration*320kbps/8, return 0 when both missing.
  - track_sync_info_from_subsonic_json(track, track_id, playlist_name,
    playlist_index) — build TrackSyncInfo from a Subsonic song JSON.
    albumArtist falls back to artist when missing or whitespace-only.
    Default suffix = "mp3".
  - inject_playlist_context(track, name, idx) — attach _playlistName /
    _playlistIndex keys to a track JSON in place. No-op when both args
    are None or the value isn't an object.

  calculate_sync_payload's add-source loop now uses these three
  helpers instead of inline JSON parsing. Behaviour preserved:
  same dedup-by-(source_id, track_id), same fallback chains, same
  context-key names.

Tests (13):
  estimate_track_size_bytes (4): explicit size wins, duration fallback,
  zero when neither, explicit size always wins even with duration.

  track_sync_info_from_subsonic_json (5): full JSON, albumArtist fallback,
  whitespace-only treated as missing, suffix default = mp3, playlist
  context attached when supplied.

  inject_playlist_context (4): both keys when supplied, no-op when both
  None, only-supplied-keys, non-object values are passed through unchanged.

Coverage delta:
  psysonic-syncfs:  50.8% -> 55.2%  (+4.4pp from inline-extraction)
  WORKSPACE:        41.6% -> 42.4%

Workspace test count: 337 -> 350. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-5d): autoeq URL builder + radio metaint + hard-pause helpers

Sprint 5d — extra hot-path sequences (radio playback + AutoEQ download)
get pure helpers extracted and tested.

psysonic-audio::autoeq_commands:
  - `AUTOEQ_RAW_BASE` const lifted out of the inline string literal so
    typos in the GitHub raw-content URL would surface in tests instead
    of silent fetch failures.
  - `autoeq_profile_url_candidates(base, source, form, name, rig?)`
    extracted from `autoeq_fetch_profile`. Pure URL builder. Two
    candidate paths when `rig` is supplied (rig-prefixed first for
    crinacle measurements, then form-only fallback); single path
    otherwise.
  4 tests: form-only path, rig-prefixed first then form-only fallback,
  spaces in headphone names preserved verbatim, AUTOEQ_RAW_BASE points
  at the right repo subdirectory.

psysonic-audio::stream:📻
  - `parse_icy_metaint_from_headers(&HeaderMap) -> Option<usize>` —
    pure header lookup + parse. Returns None for absent / non-ASCII /
    non-numeric values. Wired into `radio_download_task`.
  - `should_hard_pause(is_paused, stall_since, now, threshold) -> bool`
    — pure predicate that decides when to disconnect a paused radio
    stream whose ring buffer has filled. Wired into the hard-pause
    branch (was inline conditional before).
  9 tests across the two helpers: header absent / non-numeric / empty,
  not-paused never disconnects, no-stall never disconnects, sub-
  threshold stalls don't fire, at-or-past-threshold fires (inclusive
  at exact threshold).

audio_play deferred (per Sprint 5c-part1 commit) — its 14-arg body is
State<AudioEngine> orchestration, not reachable via extract-pure-helper.

Coverage delta:
  psysonic-audio:  39.9% -> 41.5%  (+1.6pp from radio + autoeq)
  WORKSPACE:       42.4% -> 43.0%

Workspace test count: 350 -> 363. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-5e): add hot-path function coverage soft gate

Sprint 5e — last piece of cuca's per-function ≥80% requirement.
Adds a soft CI gate that warns (but doesn't fail) when a function
listed in `.github/hot-path-functions.txt` is below 80% region
coverage.

.github/hot-path-functions.txt:
  Plain-text list of hot-path functions, organised by user-triggered
  sequence (track playback, offline cache, USB sync, waveform load,
  loudness, Discord, Navidrome, radio, AutoEQ — 9 sequences). Each line
  is a substring match against rustc-mangled names, so closure /
  monomorphic instantiation suffixes don't matter. Comments via `#`.

scripts/check-hot-path-coverage.sh:
  Reads `target/llvm-cov/cov.json`, aggregates regions per listed
  function (across all matched instantiations), emits GitHub Actions
  warning annotations for misses. Exit code stays 0 — soft gate. Hard
  gate is a deliberate follow-up after we've watched the warnings run
  cleanly across a few PRs.

  Requires jq + awk. Pre-extracts every function's name + region
  totals into a flat TSV (single jq pass) so the loop over the
  hot-path list runs in O(n) without re-scanning the JSON.

.github/workflows/rust-tests.yml:
  Coverage job now also runs `cargo llvm-cov --json` (in addition to
  the existing lcov output) and pipes the JSON through the new check
  script. Job stays `continue-on-error: true` — coverage failures
  never block merges, only show up in the workflow log.

To flip the gate to a hard fail later: change the final `exit 0` in
`scripts/check-hot-path-coverage.sh` to `exit ${BELOW}` (or `exit 1`
when `BELOW > 0`). Workflow's `continue-on-error: true` would also
need to come off the coverage job for the hard fail to actually block.

No code changes — pure tooling addition. cargo test + clippy
unchanged, all 363 tests still passing.

* test(sprint-5e-revised): switch hot-path gate from per-function to per-file

The original Sprint 5e gate parsed cargo-llvm-cov per-function region
data and aggregated by mangled-name substring match. That metric turned
out unreliable for our codebase:

  1. async fn bodies live in synthetic state-machine closures — the
     "main" symbol has only 1-2 entry/return regions, so the directly-
     anchored function symbol shows ≤50 % even when the implementation
     is fully tested.

  2. Generic functions (e.g. `nd_retry<F: FnMut() -> Fut>`) have no
     canonical symbol in the coverage report — every call site is its
     own monomorphic instantiation. Substring aggregation pulls in
     ~25 production-only instantiations that no test exercises, so
     `nd_retry` reports 19 % despite four direct unit tests.

  3. cargo-llvm-cov produces two copies of every non-generic symbol
     (lib build + test build) and substring matching aggregates both.

Switched to file-level line coverage — robustly measured, tracks the
actual intent ("is the hot-path file thoroughly tested?"), no symbol-
mangling pitfalls.

.github/hot-path-files.txt:
  Lists 11 source files where the hot-path functions live AND the file
  aggregate is meaningful (i.e. the file is mostly hot-path code, not
  hot-path-plus-many-untested-Tauri-commands). Files with mixed content
  (sync/batch.rs, navidrome/queries.rs, remote.rs, etc.) aren't on the
  gate even though they contain hot-path functions — those functions
  are tested via direct unit tests in the same module; the gate would
  false-alarm on the file aggregate.

scripts/check-hot-path-coverage.sh:
  Reads `target/llvm-cov/cov.json`, looks up `data[0].files[].summary.
  lines.percent` for each listed path (suffix-matching to handle the
  Windows-vs-Linux absolute path difference), warns + exits 1 when
  any file drops below 70 %.

  Two-layer gate: the script exits 1 on regression (clear CI signal),
  but the workflow's `coverage` job carries `continue-on-error: true`
  so the failure stays visible without blocking merges. Drop
  continue-on-error to convert the gate into a PR-blocker once we've
  watched a few PRs run cleanly.

Verified locally: all 11 listed files clear 70 %.
  fs_utils.rs           95.7%
  offline.rs            79.9%
  file_transfer.rs      96.0%
  store.rs              91.0%
  compute.rs            85.7%
  decode.rs             73.1%
  stream/icy.rs        100.0%
  progress_task.rs      90.1%
  ipc.rs                86.5%
  discord.rs            79.6%
  navidrome/client.rs   97.4%

Removed the now-superseded `.github/hot-path-functions.txt`. Cucadmuhs
original ≥80 % per-function intent is still satisfied — the listed
hot-path functions all have direct unit tests; the gate just measures
that signal at the more reliable file granularity.

cargo test + clippy unchanged, 363 tests still passing.

* style: fix needless_return in log_timestamp_local

rustc 1.95 clippy flags the trailing 'return' as needless. Drop the
keyword to satisfy '-D warnings' on CI.

* style: satisfy rustc 1.95 clippy in psysonic-audio Linux paths

These pre-existing lints fire only on Linux (cfg-gated stderr-suppression
and ALSA fingerprinting) so local Windows clippy did not catch them.

- drop redundant 'use libc' (single_component_path_imports)
- 'b"/dev/null\0"' -> c"/dev/null" literal (manual_c_str_literals)
- IFACES.iter().any(|&i| i == s) -> IFACES.contains(&s) (manual_contains)

* style: fix two more rustc 1.95 clippy errors in Linux paths

- perf.rs: needless_return on PerformanceCpuSnapshot tail
- logging.rs: redundant 'use libc' (single_component_path_imports)

* ci(rust-tests): mkdir target/llvm-cov before writing cov.json

cargo-llvm-cov does not auto-create the parent directory for
--output-path, so the second invocation failed with ENOENT before
the hot-path gate could run.
2026-05-10 22:39:35 +02:00
Maxim Isaev cdd7cb192d fix(analysis): map waveform bins to decoded length, not inflated n_frames
Container-reported frame counts can exceed decoded samples on some VBR or
badly tagged files; using max() squashed energy into the leading bins.
2026-05-10 01:43:00 +03:00
Maxim Isaev 308eb36f05 feat(analysis): re-analyze waveform when clearing loudness cache
Add analysis_delete_waveform_for_track, invoke it from loudness reseed,
clear waveformBins in the UI, and extend queue strings for tooltips/toast.
2026-05-10 01:34:40 +03:00
Frank Stellmacher 7a0dd93f3e fix(refactor): cfg-gate two items so macOS build is warning-clean (#530)
Mac smoke build surfaced 5 dead-code / unused-import warnings, all
cfg-leaks of items that are conditionally compiled on Windows + Linux:

- `psysonic-audio::power_resume` is consumed by `power_notify_win` and
  `power_notify_linux` only — `register_post_sleep_audio_recovery`
  intentionally falls through to a no-op on macOS (the generic device
  watcher covers the resume case there). Gate the module declaration
  to `#[cfg(any(target_os = "windows", target_os = "linux"))]`.

- `lib_commands::ui::build_mini_player_window` is re-exported for the
  Windows-only pre-create path in `lib.rs:setup` (other platforms
  create the mini-player webview lazily on first invoke). Gate the
  re-export to `#[cfg(target_os = "windows")]` so non-Windows builds
  don't warn on an unused import.

Both are non-functional — the items themselves are already correctly
scoped via cfg in their consumers; only the declarations / re-exports
were missing the matching gate.
2026-05-09 23:34:23 +02:00
Psychotoxical 37c19237f3 fix(refactor): restore Windows build on backend-pilot
- Re-export `build_mini_player_window` from `lib_commands::ui` so the
  Windows-only pre-create call in `lib.rs` resolves through the
  `use lib_commands::*` glob (was lost during the M5 split).
- Gate the `is_tiling_wm` import in `ui::mini` and its re-export in
  `lib_commands::sync` behind `cfg(target_os = "linux")`, the only
  platform that actually consults it.
2026-05-09 17:38:40 +02:00
Psychotoxical 97f06459f3 refactor: move analysis admin commands into psysonic-analysis (M6/7)
Reframed M6 from "extract psysonic-commands" to "place each domain's
Tauri commands in its own domain crate" — the original psysonic-commands
proposal would have been a thin shell with no clear domain ownership
because each prior milestone already kept its own commands inline:

  audio_*_commands           in psysonic-audio
  cache/sync commands        in psysonic-syncfs
  navidrome/discord/etc      in psysonic-integration

The leftover analysis admin commands (7 of them) logically belong to
the analysis domain. So they move there:

  src/lib_commands/app_api/analysis.rs   →
    crates/psysonic-analysis/src/commands.rs

  WaveformCachePayload + LoudnessCachePayload  moved out of top-crate
                                               lib.rs into commands.rs

PlaybackQueryHandle gets a second closure (`should_defer_backfill`) so
analysis_enqueue_seed_from_url can ask "is a ranged playback already
going to seed this track?" without depending on psysonic-audio.

Top crate keeps the shell-flavored commands (window/tray/mini-player,
greet/exit_app, mpris/global-shortcuts/check_dir_accessible, perf,
cli_bridge) for M7 to clean up.

Behaviour preserving. Cargo check + clippy --workspace clean.
2026-05-09 14:00:33 +02:00
Psychotoxical 98d8ea6353 refactor: extract psysonic-integration crate (M5/7)
Moves all outbound external-service bridges out of the top crate into a
new psysonic-integration crate.

  crates/psysonic-integration/
    src/discord.rs                     Rich Presence + iTunes artwork
    src/navidrome/{client,covers,...}  Native REST API admin (5 modules)
    src/remote.rs                      radio-browser, last.fm, ICY meta,
                                       generic CORS proxy (fetch_url_bytes
                                       / fetch_json_url / resolve_stream_url)
    src/bandsintown.rs                 events for an artist
    src/lib.rs                         module declarations + macro re-exports

Top crate keeps `crate::discord` working via
`pub use psysonic_integration::discord;`.

invoke_handler! in lib.rs uses full deepest paths
(`psysonic_integration::navidrome::users::nd_list_users`, etc.) so
Tauri's `__cmd__*` magic macros resolve at the right module — same
pattern audio + syncfs already use.

Cross-crate refs migrated:
  crate::subsonic_wire_user_agent → psysonic_core::user_agent::*
  pub(crate) → pub                  (cross-crate visibility)

Behaviour preserving. Cargo check + clippy --workspace clean.
2026-05-09 13:51:28 +02:00
Psychotoxical 9417d522f3 refactor: extract psysonic-syncfs crate (M4/7)
Moves all on-disk cache + device-sync code out of the top crate:

  crates/psysonic-syncfs/                      new lib crate
    src/cache/{offline,hot,downloads,fs_utils} unchanged behaviour
    src/sync/{batch,device}                    (no tray.rs — that's UI)
    src/file_transfer.rs                       shared HTTP helpers
    src/lib.rs                                 + DownloadSemaphore type
                                               + sync_cancel_flags fn

The shell crate keeps `lib_commands/sync/tray.rs` (OS tray icon — UI
concern, will move to shell-tauri at M7) but drops the rest of
`lib_commands/cache/` and the syncfs sibling files.

Tauri command quirk surfaced and resolved: `#[tauri::command]` puts its
`__cmd__*` and `__tauri_command_name_*` helper macros at the *exact*
module of the function, and `pub use` doesn't carry them across module
boundaries. invoke_handler! in lib.rs now references each syncfs
command via its full deepest path
(`psysonic_syncfs::cache::offline::download_track_offline`, etc.) so
Tauri's macros resolve at the right scope. Same approach the audio
crate already uses (`audio::commands::audio_play`).

Cross-crate ref migrations applied via batch sed:
  crate::audio::*               → psysonic_audio::*
  crate::analysis_runtime::*    → psysonic_analysis::analysis_runtime::*
  crate::analysis_cache::*      → psysonic_analysis::analysis_cache::*
  crate::subsonic_wire_user_agent → psysonic_core::user_agent::*
  super::super::file_transfer:: → crate::file_transfer::

Behaviour preserving. Cargo check + clippy --workspace clean.
2026-05-09 13:45:53 +02:00
Psychotoxical 41e75663f1 refactor: extract psysonic-audio crate (M3/7)
Moves all audio playback code (Symphonia decode, rodio output, HTTP
streaming, gapless, previews, and the seven stream/ source-type
submodules from the prior split) out of the top crate into a new
psysonic-audio crate.

  crates/psysonic-audio/                  new lib crate, depends on
                                          psysonic-core + psysonic-analysis
    src/{engine,helpers,decode,…}.rs      flattened layout (no more
                                          extra audio/ namespace level)
    src/stream/                           seven submodules from M0
    src/lib.rs                            re-exports macros from
                                          psysonic-core and the public
                                          API surface

The audio↔analysis edges identified in the dep survey are now real
crate deps (audio depends on analysis directly: AnalysisCache reads,
recommended_gain_for_target, submit_analysis_cpu_seed). Only the
analysis→audio back-edge goes through the PlaybackQueryHandle port
registered in M2.

Cross-crate ref migrations applied via batch sed:
  crate::audio::*               → crate::*       (intra-crate)
  crate::analysis_cache::*      → psysonic_analysis::analysis_cache::*
  crate::submit_analysis_cpu_seed → psysonic_analysis::analysis_runtime::*
  crate::subsonic_wire_user_agent → psysonic_core::user_agent::*

Top crate keeps `crate::audio::*` paths working via
`pub use psysonic_audio as audio;` — lib_commands/cli callers untouched.
`stop_audio_engine` (mac process-exit cleanup) moved into the audio
crate as `pub fn stop_audio_engine` since it reaches AudioEngine
internals; tray.rs now re-exports the moved fn.

Two small visibility promotions in engine.rs:
  pub(crate) fn analysis_track_id_is_current_playback   → pub
  pub(crate) fn ranged_loudness_backfill_should_defer    → pub

Behaviour preserving. Cargo check + clippy --workspace clean.
2026-05-09 13:33:44 +02:00
Psychotoxical ff456dd823 refactor: extract psysonic-analysis crate (M2/7)
Moves analysis_cache + analysis_runtime out of the top crate into a new
psysonic-analysis crate, plus the runtime user-agent facade into
psysonic-core. The audio↔analysis dependency cycle is broken via a
PlaybackQueryHandle port registered as Tauri State.

  crates/psysonic-analysis/                new lib crate
    src/analysis_cache/{mod,store,compute} unchanged behaviour
    src/analysis_runtime.rs                + enqueue_analysis_seed (was
                                           in lib_commands/cache/offline)

  crates/psysonic-core/src/
    user_agent.rs                          subsonic_wire_user_agent +
                                           runtime/default helpers
                                           (was in top lib.rs)
    ports.rs                               PlaybackQueryHandle: closure
                                           wrapper, not Arc<dyn Trait>,
                                           so existing State<AudioEngine>
                                           callsites stay unchanged

The shell setup hook registers the real PlaybackQueryHandle once the
AppHandle is available; the closure captures it and re-resolves
AudioEngine via try_state at each call.

Top crate keeps `crate::analysis_cache`, `crate::analysis_runtime`,
`crate::subsonic_wire_user_agent`, and `crate::submit_analysis_cpu_seed`
working via re-exports — no audio/lib_commands callsite needed editing.

Behaviour preserving. Cargo check + clippy --workspace clean (only
pre-existing warnings carry over).
2026-05-09 13:25:04 +02:00
Psychotoxical 7718ac3ee5 refactor: introduce cargo workspace + psysonic-core crate (M1/7)
First milestone of the workspace crate split. Sets up the workspace
skeleton and extracts shared logging + cross-crate port traits into a
new psysonic-core crate.

  src-tauri/Cargo.toml            now defines [workspace]; top package
                                  becomes the workspace root.
  crates/psysonic-core/           new lib crate, no Tauri-handler code:
    src/logging.rs                full logging facade (was src/logging.rs)
    src/ports.rs                  PlaybackQuery + AnalysisOrchestrator
                                  trait declarations (no impls yet)

The top crate re-exports `psysonic_core::logging` and the
`app_eprintln!` / `app_deprintln!` macros so every existing
`crate::logging::*` and `crate::app_eprintln!` callsite keeps working
unchanged.

Port traits intentionally take `tauri::AppHandle` — psysonic-core is a
workspace-internal crate, so depending on Tauri here is a feature, not
a leak. Implementers will register themselves as
`Arc<dyn PlaybackQuery>` / `Arc<dyn AnalysisOrchestrator>` Tauri State
in M2/M3.

Behaviour preserving. Cargo check + clippy --workspace clean.
2026-05-09 13:06:28 +02:00
Psychotoxical 9455879044 refactor(audio): split stream.rs into stream/ submodules
Pure file-move refactor: 1000-LOC stream.rs → stream/ directory with
seven cohesive submodules and explicit pub(crate) re-exports:

  icy.rs         (109)  ICY metadata state machine + parser
  reader.rs      (110)  AudioStreamReader (ringbuf → Read shim)
  local_file.rs   (32)  LocalFileSource (psysonic-local://)
  ranged_http.rs (382)  RangedHttpSource + ranged_download_task
  radio.rs       (187)  RadioLiveState + radio_download_task
  track_stream.rs (182) track_download_task (one-shot)
  mod.rs          (48)  re-exports + shared tuning constants

Source-type lifecycles are now isolated: each MediaSource impl lives
next to its download task. External callers (radio_commands,
transport_commands, play_input, engine, helpers) keep their existing
`super::stream::{...}` paths via the mod.rs re-exports — no caller
edits required.

Behaviour preserving. Cargo check + clippy clean (only the pre-existing
"too many arguments" warning on track_download_task carries over).
2026-05-09 12:55:43 +02:00
Psychotoxical 0992113269 fix(audio): suppress audio:error toast for superseded plays
Rapid skipping while a track is in initial preparation produced a
misleading "Couldn't play track — skipping" toast for the abandoned
track. Sequence: audio_play(A) reaches build_source_from_play_input;
user skips → audio_play(B) bumps gen; A's RangedHttpSource::read sees
the gen mismatch and returns Ok(0); Symphonia's probe interprets Ok(0)
as EOF → "ranged-stream: format probe failed: end of stream";
audio_play(A)'s map_err emits audio:error unconditionally → toast
appears even though playback already moved on.

Gate the emit on a generation check: if the global generation has
already moved past this play's gen, the failure is supersedion, not a
real codec error — log it but do not surface the toast.

Pre-existing bug, identical code path on main (commands.rs:600). Noticed
on refactor/backend-pilot during cucadmuh's skip-test session because
the live-test pattern hit the narrow probe-window race repeatedly.

The diagnostic logs added in the previous commit should make the next
occurrence's cause visible regardless of whether the toast fires.

Frontend impact:
- handleAudioError no longer fires on supersedion → no toast, no
  setState({ isPlaying: false }), no queued next() call.
- The audio_play IPC promise still rejects with the same error string
  (preserved as the .map_err return value); the JS .catch is already
  generation-guarded in playerStore so this is a no-op there.
2026-05-09 01:48:38 +02:00
Psychotoxical ff4271181c diag(audio): trace ranged-stream supersedion + abort paths
Skipping tracks while a RangedHttpSource is still in initial probe leaves
no diagnostic trail today: RangedHttpSource::read returns Ok(0) on
gen-mismatch (silent), ranged_download_task drops out the same way, and
the `dl done` summary only fires under app_deprintln so release users
never see partial/aborted downloads either.

Add focused logs at the bail points without changing behaviour:

- RangedHttpSource::read — log on each Ok(0) return that isn't the normal
  pos>=total_size EOF (superseded before/during wait, download done with
  no bytes ahead of cursor). Symphonia stops reading after Ok(0), so at
  most one log per source, no spam.
- ranged_download_task — log on the gen-mismatch bail with track id +
  gen transition + downloaded/total bytes so we can tell "user skipped
  while downloading" apart from "stream stalled".
- track_download_task — same gen-mismatch log for the legacy
  non-seekable path (consistency with ranged).
- dl-done summary — split into release-visible `[stream] ranged dl
  ABORTED: …` (downloaded < total_size) vs the existing dev-only
  `[stream] dl done` for full completions.
- audio_play — log on both supersedion-bail points around
  select_play_input so a silently-ending audio_play call leaves a trace.

No semantics changed: every existing return / store / branch is intact.
Pure additive logging to make the next reproduction of cucadmuh's
ranged-stream toast diagnosable.
2026-05-09 01:47:02 +02:00
Psychotoxical 176382e0b6 refactor(lib_commands): drop super::* + glob in app_api + lib_commands root
Hotspot G final slice — replace cascade-imports in app_api/{core,
analysis,integration,remote,platform}.rs with explicit per-symbol
imports, and convert app_api/mod.rs from broad `pub(crate) use foo::*`
to a per-command list of every Tauri handler.

The remaining glob re-exports live only in lib_commands/mod.rs itself
— and those are deliberate: they flatten the explicitly-listed Tauri
commands one more level so lib.rs's `use lib_commands::*` keeps the
invoke_handler shape unchanged. Each level above is now explicit.

file_transfer.rs's `super::subsonic_wire_user_agent()` becomes
`crate::subsonic_wire_user_agent()` since the lib_commands cascade
no longer pulls lib.rs symbols into super scope.

`grep -rn 'use super::\*' src/lib_commands/` returns zero hits.
Behaviour-preserving.
2026-05-08 16:45:46 +02:00
Psychotoxical 1ff8bdd8c7 refactor(lib_commands): drop super::* + glob re-export in sync + lib.rs
Hotspot G next slice — replace cascade-imports in lib_commands/sync/
{device,batch,tray}.rs with explicit per-symbol imports + per-command
re-exports.

sync/mod.rs lists each Tauri command from device / batch / tray
explicitly, plus the three internal helpers consumed by lib.rs and
ui/mini.rs (is_tiling_wm, stop_audio_engine, try_build_tray_icon).
The previous broad `pub(crate) use device::*` etc. is gone.

Inside the .rs files: each `use super::*` is replaced with what the
file actually needs — `super::device::{TrackSyncInfo, ...}` for batch,
`crate::tray_runtime::{Tray*}` + `crate::audio` + `super::super::ui::
{PAUSE_RENDERING_JS, RESUME_RENDERING_JS}` for tray, etc.

Cleanup in lib.rs: drop the now-unused tauri menu/tray imports
(MenuBuilder/MenuItemBuilder/PredefinedMenuItem/TrayIcon*/MouseButton*)
— tray.rs owns those now. Drop `Ordering` (no longer used at lib.rs
scope). Drop `pub(crate) use file_transfer::*;` from
lib_commands/mod.rs (file_transfer is now imported by direct path
where needed: `super::super::file_transfer::*`).
2026-05-08 16:32:04 +02:00
Psychotoxical 409d8fa964 refactor(lib_commands): drop super::* + glob re-export in ui + cache
Hotspot G first slice — replace the cascade-import pattern in
lib_commands/ui/ and lib_commands/cache/ with explicit per-symbol
imports + per-command re-exports.

ui/mod.rs no longer reaches `pub(crate) use mini::*; pub(crate) use
bandsintown::*;` — instead it explicitly re-exports the 8 mini-player
Tauri commands + the 3 internal helpers consumed by lib.rs +
sync/tray.rs (PAUSE_RENDERING_JS, RESUME_RENDERING_JS,
persist_mini_pos_throttled), and fetch_bandsintown_events.

cache/mod.rs likewise lists each Tauri command from offline / hot /
downloads explicitly. The only cross-crate "internal" re-export is
`enqueue_analysis_seed` which analysis_runtime depends on.

Inside the .rs files: every `use super::*` is replaced with the
specific imports actually needed (super::offline::..., crate::audio,
crate::analysis_runtime::..., tauri::Manager, etc.).

Behaviour-preserving — no Tauri command name changed, no runtime
behaviour shift. Just removes the leak-everything-everywhere import
graph that cucadmuh's policy doc forbids.
2026-05-08 16:18:51 +02:00
Psychotoxical f43ab94cc1 refactor(app_api): split navidrome.rs into 5-way module directory
Convert app_api/navidrome.rs (655 LOC monolith) into navidrome/ with
five focused submodules + a thin mod.rs:

- client.rs (106 LOC) — auth + retry + http client (navidrome_token,
  NdLoginResult, nd_err, nd_retry, nd_http_client). Internal-only,
  not re-exported at crate scope.
- covers.rs (107 LOC) — 4 multipart image-upload commands
  (upload_playlist_cover / upload_radio_cover / upload_artist_image /
  delete_radio_cover).
- users.rs (138 LOC) — login + admin user CRUD (navidrome_login +
  nd_list/create/update/delete_user).
- queries.rs (207 LOC) — songs, role-filtered artists/albums,
  libraries, per-user library assignment, absolute song path.
  Includes the nd_build_filters helper.
- playlists.rs (120 LOC) — playlist CRUD with smart-rules payload
  passthrough (nd_list / create / update / get / delete _playlist).

mod.rs is now ~28 LOC of declarations + per-module re-exports of the
Tauri commands. The cascade `app_api/mod.rs` → `pub(crate) use
navidrome::*` keeps lib.rs invoke_handler registrations unchanged.

Behaviour-preserving — pure file moves with `super::client::*` imports
where the auth/retry helpers are needed.
2026-05-08 15:59:39 +02:00
Psychotoxical 7e8acd86d6 refactor(audio): extract sink swap + crossfade handoff into helper
Pull the atomic sink-swap block (~50 LOC) out of audio_play into
play_input::swap_in_new_sink. The helper takes a SinkSwapInputs
struct (sink, duration_secs, volume, gain_linear, fadeout trigger +
samples, crossfade flag, measured fade seconds) and:

1. Locks state.current, atomically replaces sink + duration + seek/play
   timestamps + volume + replay-gain + fade-out handles, returns the
   old sink + its old fade-out handles.
2. If crossfade is on: stores total fade samples, flips trigger atomic
   on the old TriggeredFadeOut, parks the old sink in fading_out_sink,
   spawns a small task that drops it after `fade_secs + 0.5 s`.
3. If crossfade is off: stops the old sink immediately.

Behaviour-preserving — same lock scope, same atomic ordering, same
cleanup-task lifetime. audio/commands.rs: 600 → 563 LOC. With this
the audio_play body has shrunk from the original ~760 LOC monolith
to a top-level orchestration of named helper calls.
2026-05-08 15:50:07 +02:00
Psychotoxical ed92a77035 refactor(audio): lift PlayInput → BuiltSource dispatch into helper
Pull the 60-LOC match in audio_play that turned a PlayInput into a
fully-wrapped rodio source out of audio_play and into
play_input::build_source_from_play_input. Returns a small PlaybackSource
struct holding both the BuiltSource and a `is_seekable` flag (only
the Streaming variant is non-seekable).

Behaviour preserved verbatim — same build_source / build_streaming_source
calls, same target_rate=0 (no app-level resampling), same
spawn_blocking decoder build for Seekable+Streaming, same error path
(`audio:error` emit + propagate).

audio/commands.rs: 642 → 600 LOC. The remaining audio_play body now
reads top-to-bottom as: ghost guard → preview clear → gapless pre-chain
check → bump generation → reuse-bytes prep → URL pin → format hint →
**select_play_input** → gen check → loudness/RG via gain_inputs →
crossfade prep → **build_source_from_play_input** → stream rate
switching → sink construction + prefill → swap sink → progress task.
2026-05-08 15:45:53 +02:00
Psychotoxical 8f976dc371 refactor(audio): unify loudness/replay-gain prep via TrackGainInputs
audio_play and audio_chain_preload were both reading the same engine
state (target_lufs, norm_mode, pre_analysis_db) and resolving the
loudness cache, then feeding it into compute_gain — but with subtly
different intermediate steps. audio_play split the resolve+post-resolve
into two operations so it could log the cache value; chain_preload
used the bundled `loudness_gain_db_or_startup` wrapper.

Lift the shared logic into `resolve_track_gain_inputs(state, app, url,
logical_id, js_loudness_gain_db) -> TrackGainInputs` in helpers.rs.
The struct returns target_lufs, norm_mode, the cache-loudness value
(for logging), and the post-resolve effective loudness for compute_gain.
Both call sites now use the same helper; behaviour-preserving.

Drops the now-unused `loudness_gain_db_or_startup` (audio_chain_preload
was its only caller). audio/commands.rs: 676 → 642 LOC.
2026-05-08 15:41:19 +02:00
Psychotoxical 7a61d50c42 refactor(audio): extract source selection from audio_play
Pull the ~300 LOC URL → PlayInput dispatch out of audio_play into a
new audio/play_input.rs:
- PlayInput enum (Bytes / SeekableMedia / Streaming)
- PlayInputContext struct holding the precomputed inputs (url, gen,
  duration_hint, format/cache hints, optional reused chained bytes)
- select_play_input() async — handles all four branches: reused
  chained bytes, psysonic-local://, ranged HTTP with format sniff,
  and the legacy non-seekable streaming fallback
- url_format_hint() — the conservative URL→extension allowlist
  helper, used to be inline in audio_play

audio_play is now focused on the orchestration above the source layer:
ghost-command guard, gapless pre-chain detection, bumping generation,
loudness/replay-gain math, fade-in setup, building the actual rodio
Source pipeline, sink swap with crossfade, spawning progress task.

audio/commands.rs: 980 → 676 LOC. play_input.rs: 385 LOC. Behaviour
preservation hinges on identical analysis-seed spawning, format-hint
fallback chain, range-detection logic, and gen-cancel checks — all
moved verbatim. Smoke test focus: psysonic-local playback, manual skip
during ranged HTTP, format-hint-less servers (legacy fallback path),
preload cache hit replay, manual skip onto pre-chained track.
2026-05-08 15:15:30 +02:00
Psychotoxical 2b4014870c refactor(app_api): extract window + WebKitGTK platform tweaks
Lift set_window_decorations + linux_webkit_apply_smooth_scrolling +
set_linux_webkit_smooth_scrolling out of app_api/core.rs into
app_api/platform.rs (~49 LOC). These are platform-tweak Tauri
commands (Linux WebKitGTK settings + Linux native-decoration toggle)
— logically separate from runtime control, telemetry, cli bridge,
or wire-UA setup.

This is the "platform" slice from cucadmuh's plan for splitting
core.rs's mixed concerns. Together with perf and cli_bridge, core.rs
is now down to runtime control (greet, exit_app), logging (3 cmds),
and UA setup — 56 LOC, focused.
2026-05-08 14:22:23 +02:00
Psychotoxical ee7c1de3d6 refactor(app_api): extract cli_bridge wrappers into own submodule
Lift the four cli_publish_* Tauri commands (player_snapshot,
library_list, server_list, search_results) out of app_api/core.rs
into app_api/cli_bridge.rs. Each is a thin pass-through to
`crate::cli::write_*_response` — the renderer-side counterpart to the
file-based IPC layer in cli/exchange.rs.

This is the "cli-bridge" slice from cucadmuh's plan for splitting
core.rs's mixed concerns. Together with the earlier perf split,
core.rs is now down to runtime-control + platform + logging slices —
one possible follow-up but each is small enough to leave as-is for now.

app_api/core.rs: 122 → 99 LOC.
2026-05-08 14:20:11 +02:00
Psychotoxical b5ae0ccf28 refactor(app_api): extract perf telemetry into own submodule
Lift PerformanceCpuSnapshot struct + the Linux /proc/stat parsers
(parse_proc_stat_line / read_total_jiffies / collect_proc_stats) +
the performance_cpu_snapshot Tauri command (~110 LOC) out of
app_api/core.rs into app_api/perf.rs. Self-contained CPU-usage
telemetry; nothing else in app_api references the helpers.

This is the "telemetry" slice cucadmuh's plan called out for splitting
core.rs's mixed runtime/platform/snapshot concerns. Other slices
(cli-bridge, runtime-control, platform window/scrolling) can follow.

app_api/core.rs: 235 → 122 LOC. lib.rs registration unchanged —
performance_cpu_snapshot is re-exported through `pub(crate) use
perf::*` in app_api/mod.rs.
2026-05-08 14:18:32 +02:00
Psychotoxical 3b5bd3f1cc refactor(audio): lift spawn_progress_task into own module
Move the per-generation progress + ended-detection task (~205 LOC)
out of audio/commands.rs into audio/progress_task.rs. The task is now
a sibling submodule that both audio_play (commands.rs) and
audio_play_radio (radio_commands.rs) import as
`super::progress_task::spawn_progress_task`, replacing the previous
`super::commands::spawn_progress_task` cross-import that radio was
using as a workaround.

audio/commands.rs: 1185 → 977 LOC. Cleanup: dropped now-unused
AtomicU32 and AudioCurrent imports from commands.rs.

What's left in commands.rs is now just audio_play (~760 LOC) and
audio_chain_preload (~195 LOC) — the playback orchestrator proper.
Splitting those further is a bigger structural job than file moves.
2026-05-08 14:14:50 +02:00
Psychotoxical e9421e58e3 refactor(audio): extract audio_preload into preload_commands
Pull audio_preload (background fetch + analysis seed for the next
track in the queue) out of audio/commands.rs into
audio/preload_commands.rs (~67 LOC). It's a self-contained
fetch-and-cache flow — distinct from audio_chain_preload (which
constructs the gapless source chain) and audio_play (which starts
playback) so it makes sense to live alongside them rather than inside
the same file.

audio/commands.rs: 1241 → 1185 LOC. lib.rs invoke_handler updated.
2026-05-08 14:05:34 +02:00
Psychotoxical 0fae33f00b refactor(audio): extract transport commands into transport_commands
Pull audio_pause / audio_resume / audio_stop / audio_seek (~210 LOC)
out of audio/commands.rs into audio/transport_commands.rs. They mutate
state.current on an already-running sink and coordinate radio
warm/cold resume — distinct concern from playback startup
(audio_play / audio_chain_preload / audio_preload).

audio/commands.rs: 1447 → 1240 LOC. Imports trimmed: TryLockError,
radio_download_task, RADIO_BUF_CAPACITY are no longer pulled in here;
they followed the transport block to its new home. lib.rs
invoke_handler updated.

The remaining commands.rs is now focused on the playback orchestration
proper (track + chain preload + initial preload + the shared
spawn_progress_task helper).
2026-05-08 14:01:56 +02:00
Psychotoxical 17099d3aee refactor(audio): extract radio playback into radio_commands
Pull audio_play_radio (~165 LOC) out of audio/commands.rs into
audio/radio_commands.rs. Live-radio playback differs from main track
playback: no gapless chain, no seek, no replay-gain, no preload —
collecting it on its own makes both modules easier to reason about.

The shared spawn_progress_task helper (still used by audio_play /
audio_chain_preload / audio_resume in commands.rs and now by radio)
moves from private to `pub(super)` so radio_commands can call it.
A follow-up could lift it into helpers.rs proper.

audio/commands.rs: 1614 → 1447 LOC. lib.rs invoke_handler updated.
Cleanup: dropped now-unused imports (`super::sources::*`,
`RadioLiveState`, `RadioSharedFlags`) from commands.rs.
2026-05-08 13:52:35 +02:00
Psychotoxical e2ca581264 refactor(audio): extract audio-stage settings into mix_commands
Pull the six configuration setters out of audio/commands.rs into
audio/mix_commands.rs (~170 LOC):
- audio_set_volume (with replay-gain-aware ramp)
- audio_update_replay_gain (resolves cache-backed loudness, computes
  gain, ramps sink, emits NormalizationStatePayload)
- audio_set_eq (10-band gains + pre-gain)
- audio_set_crossfade
- audio_set_gapless
- audio_set_normalization

These are pure AudioEngine state mutations + (for normalization) an
ipc emit. They don't drive playback; collecting them in one place
makes commands.rs more focused on the playback orchestrators.

lib.rs invoke_handler updated. audio/commands.rs: 1784 → 1614 LOC.
2026-05-08 13:41:24 +02:00
Psychotoxical e8643c4059 refactor(audio): extract AutoEQ proxy commands into own module
Pull autoeq_entries + autoeq_fetch_profile out of audio/commands.rs
into audio/autoeq_commands.rs (~52 LOC). They proxy autoeq.app +
GitHub raw content via Rust to bypass WebView CORS — pure HTTP-fetch
flow with no playback state coupling, so they don't need to live next
to the playback orchestrator.

lib.rs invoke_handler updated to register them under
`audio::autoeq_commands::*`. audio/commands.rs: 1830 → 1784 LOC.
2026-05-08 13:35:43 +02:00
Psychotoxical 605021102f refactor(audio): peel device commands out of commands.rs
Hotspot J first slice: split the device-listing + device-selection
commands out of audio/commands.rs (1912 LOC monolith) into a new
audio/device_commands.rs (~95 LOC). Moved:
- audio_canonicalize_selected_device
- audio_list_devices_for_engine (kept pub for cli/exchange.rs callers)
- audio_list_devices
- audio_default_output_device_name
- audio_set_device

audio/mod.rs re-exports audio_default_output_device_name +
audio_list_devices_for_engine from the new submodule. The four
#[tauri::command] device handlers are registered in lib.rs run() under
their new path `audio::device_commands::*`.

audio/commands.rs goes from 1912 → 1830 LOC and drops its
`use super::dev_io::*` since playback/radio/EQ don't touch device
enumeration. Behaviour-preserving — same Tauri commands, same dev_io
helpers, same selected_device + stream_handle mutations.
2026-05-08 13:08:38 +02:00
Psychotoxical 3f46ab7c72 refactor(cli): extract Linux single-instance IPC into linux_forward submodule
Pull the Linux-only single-instance D-Bus stack out of cli/mod.rs into
a new cli/linux_forward.rs:
- tauri_identifier (reads identifier from embedded tauri.conf.json)
- single_instance_bus_name + single_instance_object_path (D-Bus path
  derivation matching tauri-plugin-single-instance)
- linux_bus_name_has_owner (zbus NameHasOwner wrapper)
- linux_is_primary_instance_running (pub)
- LinuxPlayerForwardResult enum (pub)
- linux_try_forward_player_cli_secondary (pub) — the heavy lifter:
  forwards argv via D-Bus ExecuteCallback, then for list/search
  commands reads the response file and prints to stdout

The whole submodule is `#[cfg(target_os = "linux")]` gated at the mod
declaration so non-Linux builds don't see it at all (cleaner than the
previous per-item gating).

Public surface preserved: lib.rs and main.rs keep importing
cli::linux_is_primary_instance_running and cli::LinuxPlayerForwardResult
unchanged via the linux-only pub use re-export.

cli/mod.rs: 696 → 550 LOC. Behaviour-preserving — same D-Bus protocol,
same response-file polling, same stdout output strings.
2026-05-08 13:00:10 +02:00
Psychotoxical cc60152762 refactor(cli): extract response-file IPC into exchange submodule
Pull the JSON response-file layer out of cli/mod.rs into a new
cli/exchange.rs:
- cli_*_path helpers (snapshot, library, server, search, audio_device)
  — XDG_RUNTIME_DIR-aware path resolvers
- write_cli_snapshot
- write_library_cli_response, write_server_list_cli_response,
  write_search_cli_response, write_audio_device_cli_response
- read_*_cli_response_blocking pollers (private to cli)
- print_*_cli_stdout wrappers (private to cli; thin "JSON or human"
  switches around the presenter functions)

Externally-visible names (cli::write_cli_snapshot, the four
write_*_cli_response, the path helpers) preserved via `pub use
exchange::*` re-export at the cli root. Internal-only functions
demoted to `pub(super)`.

cli/mod.rs: 895 → 696 LOC. Behaviour-preserving — same file paths,
same atomic write-via-tempfile pattern, same poll intervals + ready
predicates.
2026-05-08 12:48:49 +02:00
Psychotoxical 89fa1217b3 refactor(cli): extract human-format presenters into own submodule
Pull the JSON→stdout formatter layer out of cli/mod.rs into a new
cli/presenters.rs:
- print_library_human, print_server_list_human, print_search_human
- print_info_human + its helpers sorted_kv, value_inline
- print_audio_devices_human (kept as `pub` and re-exported from mod.rs)

Each takes a `serde_json::Value` and writes to stdout. No mutation,
no IO besides println, no inward calls back into mod.rs. cli/mod.rs
imports them via `use presenters::{...}` so the existing call sites
(print_*_cli_stdout wrappers, run_info_and_exit) read unchanged.

cli/mod.rs: 1151 → 895 LOC. Behaviour-preserving — same output
strings, same column separators, same scope handling for search.
2026-05-08 12:39:52 +02:00
Psychotoxical fff78c8f22 refactor(cli): convert cli.rs to module dir; extract parse layer
Convert the 1485-LOC cli.rs into a cli/ module directory and lift the
parse-only layer into its own submodule. cli/parse.rs now owns:
- the CliCommand / PlayerCliCmd / RepeatCliMode / SearchCliScope /
  MixCliMode enums
- the CliActionRegistry parser (reads shortcutActions.ts at startup)
- all wants_* flag helpers (--version, --info, --logs, --tail, --json,
  --quiet, --follow, logs_tail_lines)
- parse_cli_command + parse_player_cli_at + parse_repeat_mode

cli/mod.rs keeps the rest for now: print_*_human presenters, exchange
write_/read_ functions, run_info_and_exit, run_tail_and_exit, Linux
single-instance + IPC forwarding, describe_cli_command,
emit_player_cli_cmd, handle_cli_on_primary_instance. Those are the
next split candidates (presenters / exchange / linux_forward).

Public surface preserved via `pub use parse::*;` so main.rs and lib.rs
keep importing cli::wants_version, cli::parse_cli_command etc. unchanged.

cli/mod.rs goes from 1485 → 1151 LOC. Behaviour-preserving — same
parsing rules, same compile-time include of shortcutActions.ts, same
registry-driven verb resolution.
2026-05-08 12:30:30 +02:00
Psychotoxical 06f5c3e328 refactor(lib): extract tray state types into own module
Move tray-related state holders out of lib.rs into a new
src-tauri/src/tray_runtime.rs:
- TrayState / TrayTooltip type aliases
- TrayPlaybackState newtype
- TrayMenuItems / TrayMenuItemsState
- TrayMenuLabels (+ Default impl) / TrayMenuLabelsState
- tray_state_icon helper

The actual tray builder + Tauri commands (try_build_tray_icon,
set_tray_tooltip, set_tray_menu_labels, toggle_tray_icon) already
lived in lib_commands/sync/tray.rs and continue to reach the types
through the existing super::* re-export chain.

lib.rs goes from 549 → 486 LOC. Behaviour-preserving — same managed
state, same Tauri State<T> registrations in run().
2026-05-08 12:24:04 +02:00
Psychotoxical fd69cb4988 refactor(lib): extract analysis runtime queues into own module
Move ~440 LOC of analysis-queue plumbing out of lib.rs into a new
src-tauri/src/analysis_runtime.rs:
- AnalysisBackfillQueueState/Shared + worker loop + lazy-init
- AnalysisCpuSeedQueueState/Shared + worker loop + lazy-init
- submit_analysis_cpu_seed
- emit_analysis_queue_snapshot_line + analysis_queue_snapshot_loop
- WaveformUpdatedPayload (only used internally)

External callers keep their existing paths via `pub(crate) use
analysis_runtime::*` re-export at the lib.rs root. Direct accesses to
the static globals from app_api/analysis.rs are replaced with a new
prune_analysis_queues(keep) function so the statics stay private.

lib.rs goes from 999 → ~540 LOC. sync_cancel_flags stays put (sync
domain, separate concern). Tray-related types stay too — they're a
separate split candidate.

Behaviour-preserving: same workers, same queues, same events, same
return shapes. cargo check clean.
2026-05-08 12:20:40 +02:00
Psychotoxical d8f3014957 refactor(analysis_cache): unify Symphonia decode-setup boilerplate
Both count_mono_frames_from_audio_bytes and decode_scan_pcm started
with the same ~25 LOC of Symphonia setup (Cursor → MediaSourceStream →
probe → track-select with two fallbacks → codec_params → decoder).
Extract that into open_decode_session(bytes) returning a DecodeSession
struct (format + decoder + track_id + timeline_hint).

Behaviour delta: count_mono_frames_from_audio_bytes previously failed
silently if the decoder couldn't be built (.ok()?). Now it logs the
same `[analysis] decoder make failed: …` line that decode_scan_pcm
already emitted. Same return value (None), only an extra debug-print.

Net: -38 +32 in compute.rs. Both call sites now read as a single line
of setup followed by their loop.
2026-05-08 12:00:54 +02:00
Psychotoxical 635a59f133 refactor(lib_commands): centralise HTTP-stream-to-disk pattern in file_transfer
Pull the duplicated `subsonic UA + timeout client builder` and
`stream → .part → rename → cleanup` flow out of cache/offline.rs,
sync/device.rs, and sync/batch.rs into a new lib_commands/file_transfer
module.

- subsonic_http_client(timeout): standard UA + single timeout. Used by
  the 3 simple-timeout sites (offline track, sync_track, batch sync).
  download_update / download_zip / fetch_netease_lyrics keep their own
  builders since they need separate connect+overall timeouts or extra
  headers.
- stream_to_file(response, dest): chunked HTTP body → file (moved from
  cache/offline.rs, where it was the de-facto shared helper anyway).
- finalize_streamed_download(response, dest, part): stream to .part
  then rename, with best-effort cleanup of .part on any failure.

Behaviour delta: the offline + device single-track flows now also clean
up an orphan .part file when the final rename fails — previously only
the batch sync did this. Strictly safer; no observable change on the
success path.

downloads.rs (download_update / download_zip) keeps its inline progress
chunk loops — those emit per-chunk progress events with flow-specific
payload shapes and intervals; sharing them would force a callback API
that's heavier than the duplication it removes.

Net delta: -53 LOC across the 3 call sites.
2026-05-08 11:53:06 +02:00
Psychotoxical 66cbf25469 refactor(analysis_cache): split SQLite store from decode/EBU compute
Convert analysis_cache.rs (901 LOC) into a module directory:
- store.rs (408 LOC): AnalysisCache + types + SQLite plumbing
  (DB path, pragmas, schema migration, all DML, track-id variant lookup)
- compute.rs (498 LOC): Symphonia decode + EBU R.128 + waveform binning
  (seed_from_bytes_execute, analyze_loudness_and_waveform, decode_scan_pcm,
  byte-envelope fallback, recommended_gain_for_target)
- mod.rs: re-exports the unchanged public surface

External callers (lib.rs, audio/helpers, audio/commands, cache/offline,
app_api/analysis) keep importing from `crate::analysis_cache::*` — same
names, same signatures, behaviour unchanged. cargo check clean.
2026-05-08 11:47:27 +02:00
Psychotoxical 480e32ba04 refactor(cache): extract dir_size + prune_empty_dirs into fs_utils
Lift the duplicated `dir_size` walker and the empty-directory upward-prune
loop out of cache/offline.rs and cache/hot.rs into a shared
cache/fs_utils.rs module. Behaviour-preserving: same boundary semantics,
same skip-on-error policy, same `remove_dir`-only pruning (never
remove_dir_all).

Net delta: -76 LOC across cache/, single source of truth for cache cleanup.
2026-05-08 11:40:37 +02:00
Frank Stellmacher dc2068303d chore(deps): bump Tauri 2.11.0 → 2.11.1 (GHSA-7gmj-67g7-phm9) (#509)
* chore(deps): bump Tauri 2.11.0 → 2.11.1 (GHSA-7gmj-67g7-phm9)

Patches the IPC origin-confusion advisory: Tauri 2.11.0 and below could
let a remote-origin page loaded inside the webview invoke local-only
IPC commands. Severity medium. Psysonic exposes a number of file-system
and credential-bearing IPC commands (download_zip, nd_get_song_path,
audio_*), so closing the gate is worth the lockfile-only bump.

Cargo.toml is unlocked at "2", so this is purely a Cargo.lock refresh
via `cargo update -p tauri --precise 2.11.1`. Full Tauri family bumped
together (tauri / tauri-build / tauri-codegen / tauri-macros /
tauri-runtime / tauri-runtime-wry / tauri-utils — all matching patch
releases). wry, tao and other transitive deps unchanged. phf 0.11.3
fell out of the dependency graph because the new tauri-utils no longer
pulls it.

Tested locally (Windows): tray hide/restore + single-instance second
launch + mini-player toggle + window-state persistence — all working.

* docs: changelog entry for PR #509

Logs the Tauri 2.11.1 GHSA security bump in v1.46.0 "## Fixed".
2026-05-07 22:43:15 +02:00