mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
7c32172d5d77945932fcee983e601bfc58d26b76
17 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
a41e3a624a |
feat(song-info): show absolute file path on Navidrome via native API (#504)
* feat(song-info): show absolute file path on Navidrome via native API
Subsonic's `getSong.view` returns at most a relative path (or none on
Navidrome), so the Path row in the Song Info modal stayed empty for
most users. Feishin and the Navidrome web client surface the full
server-side path by hitting Navidrome's native `/api/song/{id}` instead.
Added an `nd_get_song_path` Tauri command that logs in to the native
API with the active server's credentials, fetches the song, and returns
the `path` string. Wired it into `SongInfoModal`: the Subsonic
`getSong` call still drives the rest of the dialog, and the native call
runs in parallel only when the active server's identity is
"navidrome". When it returns a path, that absolute path replaces the
relative Subsonic value; native-API failures are silent and the modal
falls back to whatever Subsonic provided.
No token cache yet — the modal is opened occasionally enough that one
fresh login per call is fine.
Closes discussion #479.
* docs: changelog entry for PR #504
Logs the Navidrome native-API absolute file path support for the
Song Info dialog in v1.46.0 "## Added".
* docs: settings contributor entry for PR #504, drop competitor mention
Adds the song-info absolute path bullet to Psychotoxical's contributors
list in Settings, and rewords the existing CHANGELOG entry so neither
text references competing clients.
|
||
|
|
59744601d4 |
feat(composer): Browse by Composer page (issue #465) (#487)
* feat(composer): Browse by Composer page (issue #465) New library section listing every artist credited as composer on at least one track, with a detail page showing all works they're credited on in that role. Targeted at classical-music libraries where the "recording artist" tag carries the orchestra and the "composer" tag carries Bach / Mozart / Chopin. Hits Navidrome's native /api/artist?_filters={"role":"composer"} for the listing and /api/album?_filters={"role_composer_id":"…"} for the works grid — Subsonic getArtist only follows AlbumArtist relations and returns 0 albums for composer-only credits, so the native API is the only path that works. Requires Navidrome 0.55+ (uses library_artist.stats role aggregation); on older / pure-Subsonic servers the page shows a one-line capability banner. - Two new Tauri commands: nd_list_artists_by_role + nd_list_albums_by_artist_role, generic over participant role so conductor / lyricist / arranger pages are trivial to add later. - Composers grid: text-only compact tiles (name + participation count pulled from stats[role].albumCount). No avatars — composer libraries carry no useful imagery and the listing endpoint exposes no image URLs anyway. - ComposerDetail: hero with Last.fm bio (via getArtistInfo2) plus the full work grid, with a graceful fallback when the artist has no external info synced. - Sidebar entry default off (Feather icon) — opt-in for the niche classical use case. - nd_retry backoffs widened from [500] to [300, 800, 1800] — helps every nd_* call survive intermittent TLS-handshake-EOF errors that some reverse-proxy setups produce when keep-alive pools churn. - Distinguishes "server can't do this" (HTTP 400/404/422/501) from transient errors so the capability banner only fires when the server actually rejects the request shape; everything else gets a retry button. - i18n in all 8 supported locales. * fix(composer): address review feedback on detail page + role queries - Re-fetch ComposerDetail when music-library scope changes; previously the album grid stayed stale until navigation while the list refreshed. - Thread library_id through nd_list_artists_by_role and nd_list_albums_by_artist_role so role queries respect the active Navidrome library, matching the Subsonic musicFolderId already piped through libraryFilterParams(). - Fix CachedImage cache-key mismatch on ComposerDetail: a Last.fm header image was stored under the Subsonic cover-art key, aliasing cache entries and risking cross-source pollution. - Consolidate the two contradictory composer-imagery comments in Composers.tsx into a single accurate one (the older one referenced an Images toggle that was never implemented). - Align openLink toast duration with ArtistDetail (1500ms -> 2500ms). * fix(composer): keep bio across scope changes, add share, degrade gracefully Three remaining items from the latest review pass on the composer flow. 1. Bio survives a music-library scope change. The previous fix added musicLibraryFilterVersion to the load effect, but that effect also did setInfo(null) while the getArtistInfo effect still depended on [id] alone — so a scope bump on the open page wiped the bio without re-fetching it. Move the info reset into the bio effect (keyed on id) and out of the load effect: the album grid still refreshes on scope change; the Last.fm header image and biography survive untouched, since both are library-independent. 2. Composers join the share pipeline as a first-class entity kind. Extend EntityShareKind with 'composer' (and isEntityKind), branch applySharePastePayload to validate via getArtist (same id pool) and navigate to /composer/:id, and wire a Share button into ComposerDetail. A pasted composer link now opens the composer view instead of the artist view, matching what was copied. i18n added in all 8 locales (sharePaste.composerUnavailable, openedComposer; composerDetail.shareComposer, unknownComposer). 3. Partial server failure no longer hides the works. If getArtist rejects but ndListAlbumsByArtistRole succeeds, the page used to show full "not found" despite having data to display. Switch the not-found gate to require both empty (`!artist && !albums`) and render a degraded header (placeholder name, no Wikipedia / favourite / share / Last.fm image) when only metadata is missing. * fix(composer): right-click share copies a composer link, not an artist link The context menu opened from a composer card / row uses type='artist' because every composer-action (radio, favourite, rating, add-to-playlist) is identical to the artist counterpart — they share an id space and a backend representation. Sharing was the one exception: the "Share Link" entry produced a 'psysonic2-' string with k='artist', so a paste opened /artist/:id even though the user came from /composers. Add an optional shareKindOverride to openContextMenu (default: undefined, preserves existing behaviour) and have the artist-typed branch consult it when calling copyShareLink. Composers.tsx now passes 'composer' on both right-click sites; nothing else changes downstream because the override only affects the share kind. * polish(composer): show Last.fm avatar even without server metadata Two minor follow-ups from the latest review. - ComposerDetail: drop the `&& artist` guard on the header-avatar render path. info?.largeImageUrl can resolve through getArtistInfo(id) without ever needing the SubsonicArtist record, so the previous gate hid a perfectly good Last.fm portrait whenever getArtist failed but the bio fetch succeeded. Replace artist.name with displayName so the alt / aria-label degrade to the localised "Composer" placeholder instead of empty strings. - copyEntityShareLink: doc comment now mentions composer alongside track / album / artist. * fix(composer): derive Last.fm cache key from route id, not from artist record Follow-up to the previous polish: the avatar render path no longer requires `artist` to be populated, but the cache-key gate still did. So when getArtist failed but getArtistInfo returned a Last.fm portrait, the key fell through to coverKey — which is empty without an artist record, re-creating the very aliasing bug the earlier Subsonic-vs-Last.fm fix was meant to close. Switch the Last.fm branch to the route id (same id namespace as the SubsonicArtist record), so the key stays stable whenever Last.fm art is shown, independent of getArtist succeeding. * docs: CHANGELOG + Contributors entry for composer browsing (PR #487) |
||
|
|
b084e96c1f |
fix: prune stale analysis queues and cap loudness backfill window (#480)
* fix(analysis): prune stale backfill jobs and limit prefetch window Drop pending backfill and cpu-seed jobs that are no longer in the active playback queue, and add debug counters for pruned work. Limit loudness backfill scheduling to the current track plus the next five tracks to prevent runaway queue growth in dev sessions. * chore(analysis): remove unused loudness prefetch parameter Drop the now-unused incoming-tracks parameter from the loudness prefetch helper and update internal call sites to match the current queue-window scheduling logic. * docs(changelog): document analysis queue control fix (#480) Add a short 1.46.0 Fixed entry describing stale backfill pruning, the current+5 loudness backfill window cap, and debug prune counters for diagnostics. * docs(contributors): add cucadmuh entry for PR #480 Logs the analysis-queue prune + loudness backfill window cap in the Settings → System → Contributors list. |
||
|
|
8d8c1aa8a3 |
Environment upgrade & hot-cache playback (#463)
* chore: upgrade dependencies and migrate playback to rodio 0.22 Bump npm and Rust crates; adapt symphonia decoding, ringbuf 0.5, lofty tags, and discord-rich-presence usage. Use native rodio Player/MixerDeviceSink and cpal device descriptions; drop the unused cpal patch. Align Vite 8 build targets and chunking; remove redundant dynamic imports and fix hot-cache debug logging imports. * perf(build): lazy-load routes and restore default chunk warnings Lazy-load all routed pages with React.lazy to shrink the main bundle; wrap root Routes in Suspense for lazy Login. Drop chunkSizeWarningLimit override so Vite uses the default 500 kB threshold. * fix(windows): tray double-click without spurious menu; clean unused import Disable tray menu on left mouse-up on Windows so a double-click to hide the main window does not immediately reopen the context menu (tray-icon default menu_on_left_click). Gate std::fs in app_api/core behind cfg(linux) for /proc-only code so Windows builds stay warning-free. * fix(sidebar): preserve new-releases read state under storage cap When merging seen album ids, keep the current newest sample first so the 500-id localStorage limit does not truncate freshly marked reads and bring back the unread badge. * fix(audio): hot-cache replay, analysis no-op skips, playback source UI Retain stream_completed_cache across audio_stop so end-of-queue replay can use RAM promote or disk hot file instead of re-ranging HTTP. Add cpu_seed_redundant_for_track gate before file/bytes seeds and local-file spawn; emit analysis:waveform-updated only on Upserted. Ranged/legacy promote checks generation after await before filling the slot. Frontend: promote on same-track and cold resume; set currentPlaybackSource on resume, queue undo restore, and gapless track switch so cache/stream icons stay accurate. Import tauri::Manager for try_state in audio_play. * fix(ts): narrow activeServerId for hot-cache promote calls promoteCompletedStreamToHotCache expects a string; bind non-null server ids in repeat-one, playTrack prev/same-track, and cold resume paths so tauri production build (tsc) succeeds. * fix(player): handle same-track hot-cache promote promise chain Add .catch for promoteCompletedStreamToHotCache → runPlayTrackBody so sync throws and unexpected rejections do not surface as unhandled in DevTools; reset defer-hot-cache prefetch and isPlaying on failure. * chore(nix): sync npmDepsHash with package-lock.json * chore(release): finalize 1.46.0 CHANGELOG with PR #463 links Document the release with full GitHub PR #463 on every subsection so entries stay attributable if sections are reordered. Fix ContextMenu lines where dynamic imports were accidentally merged onto one line. * docs(contributors): credit cucadmuh for #463 |
||
|
|
a6cc2e2ad4 |
perf(linux): WebKit probe, throttled progress IPC, snapshot playback UI (#452)
* feat(linux): optional native GDK for Nix gdk-session Introduce PSYSONIC_ALLOW_NATIVE_GDK so main skips the default GDK_BACKEND=x11 pin when the Nix gdk-session wrapper sets the flag. Remove GDK_BACKEND from the npm tauri:dev script so it does not override nix develop defaults. * fix(ui): portal server switch menu above sidebar Main column stacks below the sidebar (layout z-index), so an in-tree dropdown could never win over the left nav. Render the menu via createPortal to document.body with fixed coordinates, matching the library scope picker. * feat(perf): add mainstage probe controls and cut WebKit repaint load Add a dedicated performance probe surface for mainstage/home toggles and wire Linux CPU diagnostics to isolate expensive UI paths. Tune waveform drawing and Home artwork clipping/windowing so visible content loads immediately while reducing WebKit compositor pressure during playback. * fix(perf): stop hero rotation when section is off-screen Gate hero auto-rotation and backdrop crossfade by real viewport visibility using the actual scrolling ancestor. This prevents periodic 10-second CPU spikes from hidden hero updates while preserving normal behavior when the hero is visible. * fix(perf): isolate player progress updates from mainstage diagnostics Add probe toggles for PlayerBar waveform and live progress UI updates to confirm playback progress churn as the main CPU driver. Restore Home artwork quality defaults and keep visual-degradation modes opt-in via debug flags only. * fix(hero): resume background and autoplay after viewport return Re-check hero visibility on focus/visibility changes and add a short recovery poll while off-screen so missed scroll/RAF events cannot leave hero animation paused. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(perf): decouple playback progress from mainstage compositing pressure Throttle audio progress delivery and route live seekbar timing through a lightweight progress channel to cut focus-time WebKit CPU spikes. Add focused diagnostics in Performance Probe and restore hero/waveform behavior so visuals remain stable while profiling. * fix(debug): open performance probe with Ctrl+Shift+D Replace logo-triggered opening with a keyboard shortcut and keep logo purely decorative to avoid accidental probe activation. * docs(changelog): document experiment/performance probe and playback work Add an [Unreleased] section for the performance probe, throttled audio progress IPC, snapshot-based live UI updates, WaveformSeek scheduling over the same canvas bar renderer, Hero/Home rail fixes, and Linux/Nix GDK dev ergonomics. * perf(linux): add WebKit probe, throttle progress IPC, snapshot playback UI Ship Performance Probe (Ctrl+Shift+D), Rust-throttled audio:progress, a playback progress snapshot channel with coarse Zustand timeline commits, Linux /proc CPU readout for the probe, Hero and Home rail artwork fixes, Tracks SongRail windowing parity, MPRIS cleanup, gated perf counters, and WaveformSeek paused-seek correctness. Documented in CHANGELOG for PR #452. * docs(changelog): fold perf work into 1.45.0 and refresh date Drop the separate 1.45.1 heading; keep PR #452 notes under 1.45.0 Added and set the section date to 2026-05-04. Restore the safety preface before the versioned sections. * docs(changelog): order 1.45.0 Added entries by PR number Sort the 1.45.0 release notes so subsections follow ascending PR id (390 through 452), with PR #452 last. --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
1e05180418 |
feat(shortcuts): action registry + dynamic CLI help + new input targets (#435)
* feat(shortcuts): unify action-driven shortcut and CLI routing Centralize shortcut action metadata in one TypeScript registry and route keyboard, global shortcut, mini-window, and CLI inputs through shared runtime handlers. Keep CLI as an abstract transport layer by emitting player-command payloads without depending on shortcut definitions. * feat(shortcuts): generate CLI action help from shortcut registry Move no-arg player commands and their descriptions into the central action registry so CLI parsing and --player help are derived dynamically from one source of truth. Also route runtime action execution through the registry and remove duplicated shortcut runtime handling. * feat(shortcuts): add new input actions and hidden F1 help binding Add the requested input actions (search, advanced search, sidebar, mute, equalizer, repeat, now playing, lyrics, favorite current track) to the central shortcut action registry and wire runtime handlers for sidebar/equalizer toggles. Keep Help bound to F1 by default while hiding it from Settings input lists, and backfill persisted keybindings with new defaults so F1 works for existing users. Requested by @zunoz (Discord community). |
||
|
|
9cc74a7f88 |
style(tauri): drop redundant use super::*; imports
Five lib_commands submodules either had `use super::*;` duplicated during the split (core.rs, offline.rs, mini.rs — leftover line 3) or didn't reference the parent scope at all (navidrome.rs, bandsintown.rs). Removing them silences all five `unused import` warnings; cargo check is now warning-free. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
cfeec22c82 |
refactor(tauri): decompose lib command surface into domain modules
Split the large lib command module into nested app_api, cache, sync, and ui submodules while preserving command signatures and behavior. This keeps command responsibilities isolated and makes further extraction safer. |