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.
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.
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.
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).
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.
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.
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.
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.
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::*`).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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().
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.
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.
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.
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.
* fix(i18n): OpenDyslexic font supports Cyrillic
The OpenDyslexic subtitle in the font picker stated "no RU/ZH support",
but the bundled `@fontsource/opendyslexic` 5.x ships Cyrillic glyphs and
Russian renders correctly — verified empirically. Only Chinese (CJK)
actually falls back to the system font. Updated the subtitle string in
all 8 locales accordingly; the Russian locale itself no longer claims
unsupported-self.
* docs(changelog): correct OpenDyslexic locale-coverage claim (#513)
The #507 entry in the unreleased v1.46.0 block claimed `Latin +
Latin-extended only` and listed Cyrillic among the system-font
fallbacks. Cyrillic glyphs ship in @fontsource/opendyslexic 5.x and
render correctly — only Chinese (CJK) actually falls back. Updated the
bullet in place to match the corrected subtitle hints from this PR.
* feat(player-bar): album context menu on song title right-click
Right-clicking the track title in the player bar now opens the same
album context menu that album cards use (open, play next, enqueue,
go to artist, favorite, rate, share, download, add to playlist).
Mirrors the existing left-click behavior on the title, which already
navigates to the album. Suppressed for radio and preview, matching
the click handler.
MarqueeText gains an optional onContextMenu prop; PlayerBar builds a
SubsonicAlbum shape from currentTrack on demand.
* docs(changelog): add entry for PR #512 (player-bar title context menu)
* 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".
* refactor(settings): collapse all sections, drop font dropdown, surface OpenDyslexic
Settings opened on a tab where four or five sub-sections were expanded
on first render — audio device, theme list, lyrics sources, sidebar
customizer, random-mix copy, offline dir, language picker, keybindings
table. The page felt like a wall of controls before the user had even
looked for something specific. Removed every `defaultOpen` flag from
the SettingsSubSection call sites so each tab now boots with only the
section headers visible. Component default was already `false`.
ThemePicker auto-expanded the group containing the active theme on
mount. Same noise on a screen that already has the longest accordion
list in the app, and the blue dot in the group header already tells
the user which group holds the active theme. Initial open-group is
now `null` — all groups collapsed until the user clicks one.
Font picker had a dropdown-style button that toggled a list inside
the sub-section, which meant two clicks (open the section, then open
the dropdown) for what should be a one-click choice. Removed the
button + the `fontPickerOpen` state — opening the Font sub-section
now reveals the full list directly and a click sets the font without
collapsing anything. OpenDyslexic moved to the top of the list so
users with dyslexia don't scroll past 14 sans-serifs to find their
option; the rest stays in the original order.
* docs: changelog entry for PR #508
Logs the Settings collapse-by-default + font picker cleanup +
OpenDyslexic ordering in v1.46.0 "## Changed".
* feat(settings): OpenDyslexic font option for dyslexic readers
Next step on the accessibility track. The first pass was on the colour
side — WCAG contrast audits across every theme and dedicated colour-
vision-deficiency variants for the protanopia / deuteranopia / tritan-
opia palettes. Typography is the other axis: some users with dyslexia
find a font with a heavier weighted baseline and asymmetric glyph
shapes (b/d, p/q never mirror, italic forms differentiated rather
than slanted-regular) easier to track than a typical sans.
Adds OpenDyslexic to the existing Fontsource font picker. SIL OFL
licensed, freely redistributable, and the de-facto open-source
standard for this use case. Non-variable axis, ships as four discrete
weight/style files (regular, bold, italic, bold-italic) — the Settings
picker grew an optional `hint` field on font entries so this one row
can carry a "dyslexia-friendly · no RU/ZH support" subtitle without
bloating the other 14 entries.
Latin + Latin-extended only. Cyrillic and CJK locales (RU, ZH) fall
back to the system font when this is selected; the subtitle calls out
that limitation upfront. i18n: hint string in all 8 locales
(settings.fontHintOpenDyslexic).
Accessibility is intentional product positioning here — it's an
underserved corner of the Subsonic-client ecosystem.
* chore(nix): sync npmDepsHash with package-lock.json
* docs: changelog entry for PR #507
Logs the OpenDyslexic font option in v1.46.0 "## Added".
* docs(settings): contributor entry for PR #507
Adds the OpenDyslexic accessibility bullet to Psychotoxical's
contributions list.
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* wip(home): Lossless rail + dedicated /lossless-albums page
Rail under Home > mostPlayed and a dedicated infinite-scroll page that
list albums whose tracks are tagged in lossless containers. Walks
Navidrome's native /api/song?_sort=bit_depth&_order=DESC, dedupes by
albumId on the way down, stops when the song stream crosses into lossy
(bitDepth==0) or the server runs out of rows. _filters has no operators
on quality columns, so a sort + walk is the only path; equality on
sample_rate / bit_depth probes returned empty (verified).
The page paginates through the song-cursor with an in-flight ref so
overlapping IntersectionObserver fires don't double-add albums, plus a
cancelled flag so React StrictMode's double-mount doesn't apply two
parallel result sets in dev.
Suffix allowlist excludes ambiguous wrappers — m4a/m4b can be ALAC
*or* AAC and Navidrome's response carries an empty codec field, so we
can't tell them apart; same story for wma. Allowlist is flac, wav,
aiff/aif, dsf/dff, ape, wv, shn, tta — containers that are only
lossless. ALAC-in-m4a setups will miss out, acceptable trade-off
without a reliable codec field.
Status: WIP. Settings home-customizer label and en.ts strings landed,
no other locales yet, no quality badge on AlbumCards across the rest of
the app, no CHANGELOG. Branch was 'feat/home-hires-rail' during the
earlier hi-res-only iteration before the lossless broadening.
* wip(home): Lossless page header parity + streaming + sidebar nav
Page header now mirrors All Albums: selection mode with three action
buttons (Enqueue Selected, Add Offline, Download ZIPs) wired to the
same handlers Albums.tsx uses, selection-counter title swap, and the
perfFlags.disableMainstageStickyHeader respect path. Filters were
intentionally skipped — the rail is sorted by bit_depth, mixing client-
side filters with server-driven pagination would produce gaps.
Loading feels noticeably faster: ndListLosslessAlbumsPage takes an
optional onProgress callback that fires once per internal fetch with
the entries discovered in that fetch, so the page can stream new
albums into state instead of waiting on the whole loadMore. Page-side
budget dropped from 5×200 to 2×100 songs per loadMore (~1 MB worst
case vs 5 MB before), since the rail's catch-em-all pass is wrong for
infinite-scroll UX. Subtitle under the page title primes the user that
this is slower than other album pages because Psysonic walks the song
catalog by quality (Navidrome ignores _fields, so per-song responses
ship with lyrics + tags + participants whether we want them or not).
Sidebar nav entry registered under 'losslessAlbums' with a Gem icon,
defaults to visible:false (matches composers / folderBrowser /
deviceSync — niche browsing modes). Existing users get the entry
appended at the end of their persisted sidebar list automatically via
the onRehydrateStorage merge that sidebarStore already runs for new
DEFAULT_SIDEBAR_ITEMS.
i18n: full coverage across all 8 locales for sidebar.losslessAlbums,
home.losslessAlbums, losslessAlbums.empty, losslessAlbums.unsupported
and the new losslessAlbums.slowFetchHint subtitle. ru/zh are
machine-translation quality, flagged for a polish pass.
* feat(home): default Lossless sidebar entry to visible
Flips the DEFAULT_SIDEBAR_ITEMS entry for `losslessAlbums` from false
to true. Existing installs keep whatever the user has in persisted
storage; fresh installs see the entry in the sidebar from the start.
Earlier wip commit defaulted it off (matched composers / folderBrowser
/ deviceSync as a niche browse mode), but the rail + page do show
something useful for any library with at least one FLAC/WAV/etc. album,
so off-by-default just hid the feature.
* docs: changelog entry for PR #506
Logs the Lossless Albums rail + page + sidebar entry in v1.46.0
"## Added".
* docs(settings): contributor entry for PR #506
Adds the Lossless Albums rail/page bullet to Psychotoxical's
contributions list.
* 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.
* fix(radio): queue navigation, dedup, and similar-first variety (#500)
After a Radio session ran a while, three things broke:
Queue navigation through duplicates. playTrack re-resolved the active
queue index by `findIndex(t.id === track.id)`, returning the *first*
matching id, so reaching the second occurrence of a track snapped
queueIndex back to the earlier slot — highlight jumped and the next
advance played the wrong follow-up. Added an optional
`targetQueueIndex` to playTrack, threaded through next(), previous(),
the audio:ended repeat-one path, queue-row click, and the queue-item
context menu. findIndex stays as the fallback for callers that just
have a track and a fresh queue.
Queue accumulation. enqueueRadio didn't dedupe incoming tracks; the
next() top-up deduped against the live queue but trimmed the played
tail down to HISTORY_KEEP=5, so a song heard 8 ago was gone from
`existingIds` and a later Last.fm/topSongs response could re-add it;
and the `.filter(...)` pass admitted intra-batch repeats (top +
similar overlap is common) because it read the dedup set before
mutating it. A module-level radioSessionSeenIds set, fed by
enqueueRadio and both top-up paths and reset on artist change and
clearQueue, closes all three: trimmed ids stay in the set, ids about
to be replaced (fresh enqueueRadio wiping the pending radio block)
are removed first so callers can re-introduce them, and the dedup
pass mutates the set inline.
Variety. Starting Radio on a track stacked five top tracks of the
seed artist before any similar-artist material played. Switched the
seed path and both top-up paths to lead with similar songs (other
artists) and only fall back to top tracks when similar comes back
empty — preserves the "no Last.fm" graceful degradation but stops
the seed artist from monopolising the front of the queue.
Not affected: gapless audio:track_switched (already index-based, no
findIndex), AudioMuse Instant Mix / Lucky Mix (single-element queues
or enqueue-only paths), the artist-radio path (no seedTrack — already
picks just one top track and fills the rest from similar).
Reported by netherguy4.
* docs: changelog entry for PR #503
Logs the radio queue navigation/dedup/similar-first fix in v1.46.0
"## Fixed".
* fix(preview): keep preview sink volume in sync with player slider (#498)
The Rust preview sink had its volume set once at audio_preview_play and
then never updated. audio_set_volume only ramps the main sink, so slider
movements during a preview had zero effect on the preview level. With
the default loudness normalization (-4.5 dB pre-analysis attenuation)
applied at start, even a 100% slider gives 1.0 × 0.596 × MASTER_HEADROOM
≈ 53% — matching the user-visible "fixed at around 50%" symptom.
- Add audio_preview_set_volume Rust command that updates the preview
sink if one is active (clamp + master headroom mirror the path used
in audio_preview_play).
- Extract the preview-volume calculation in previewStore into
computePreviewVolume() so startPreview and the new sync path share
one formula (slider value, plus the LUFS pre-analysis attenuation
the engine already applies to the main sink).
- Subscribe to playerStore at module level: when volume changes and a
preview is active, push the recomputed value to Rust. Auth /
normalization tweaks during preview are intentionally not synced —
preview is short and that case is rare.
Reported by netherguy4.
* docs: changelog entry for PR #502
Logs the preview-volume-slider sync fix in v1.46.0 "## Fixed".
* fix(tray): resume rendering on second-launch restore (#497)
The single-instance plugin callback that handles a second launch (e.g.
via desktop / start-menu shortcut while the main window is hidden in
the tray) was missing the RESUME_RENDERING_JS injection that the tray-
icon restore path already does.
When the main window is closed to the tray, PAUSE_RENDERING_JS sets
window.__psyHidden = true, marks <html data-psy-native-hidden="true">,
and zeroes --psy-anim-speed. A global CSS rule then pauses every
animation under <html>. Page wrappers across the app use
.animate-fade-in (animation: fadeIn ... both) which starts at
opacity: 0 — so when a route mounts after navigation the new wrapper
stays frozen at opacity: 0 and the page looks blank.
Restoring via the tray icon worked because that path injects
RESUME_RENDERING_JS before show(); only the second-launch path was
missing it. Mirror the same eval here so both restore paths are
consistent.
Reported by netherguy4.
* docs: changelog entry for PR #501
Logs the tray second-launch restore-rendering fix in v1.46.0 "## Fixed".
* refactor(settings): remove redundant Animations 3-state setting under Seekbar Style
The `animationMode` setting (Full / Reduced / Static) duplicated work
the perf-flag system and OS-level reduced-motion preference already
covered:
- `perfFlags.disableMarqueeScroll` already kills marquee scrolling on
demand, replacing what `static` mode used to gate.
- The `data-perf-disable-animations` html-level switch already strips
every `*` animation, replacing what `static` mode used to do globally.
- `@media (prefers-reduced-motion: reduce)` honours the OS setting for
every user that asked for it via system preferences.
- The 30 fps cap that `reduced` mode applied to the seekbar wave was
better served by per-feature perf toggles cucadmuh added later.
Removed:
- `AnimationMode` type, `animationMode` field + setter from auth store.
- Settings UI block (3 buttons + hint text) under Appearance > Seekbar
Style.
- `animationMode === 'static'` short-circuit in WaveformSeek's rAF
effect; `isReduced` skip-every-other-frame logic; `static`-checks in
`drawNow` / `needsDirectDraw`.
- `animationMode !== 'static'` guard and `data-anim-mode` attribute in
MarqueeText.
- `[data-anim-mode="static"]` and `[data-anim-mode="reduced"]` rules in
layout.css.
- Seven i18n keys (animationMode + 6 variants) across all eight
locales.
Migration: the persist layer strips `animationMode` (and the legacy
`reducedAnimations` boolean predecessor) so anyone who had `'reduced'`
or `'static'` selected silently lands on the former `'full'` path on
first launch after upgrade. No user-facing prompt — the missing setting
just stops existing.
cucadmuh's PR #472 (FPS overlay), #476 (preview-freeze main seekbar,
sleep-recovery hooks, card-hover removal) and #486 (interpolation
anchor reset on resume) are all preserved untouched — they live in
separate effects / files and were not driven by `animationMode`.
* docs(changelog): add Removed section for animationMode setting (PR #495)
* docs(changelog): refine animationMode removal rationale (drop prefers-reduced-motion overstatement)
Two reports rolled into one pass:
1. Rail occasionally rendered nothing on Home open. Cause: the rotated
anchor sometimes had no Last.fm similar-artists or no library matches
among the sampled set, and the old code gave up after one try and
stored that dud anchor as the rotation cursor — so the next mount
started from the same dud's neighbour and could fail again.
2. Recommendations felt repetitive. Cause: pool of 12 anchors walked
round-robin made each anchor recur every 12 mounts, similar-artist
sample of 6 from 12 had heavy overlap visit-to-visit, and per-artist
single-album random pick meant artists with one library album always
surfaced the same record.
Anchor selection: random pick from pool with a per-server cooldown
buffer (last 5 anchors excluded, capped at floor(pool/2) so small
libraries don't soft-lock). Up to 4 anchors are tried in a shuffled
candidates list before giving up; the localStorage cursor only advances
on a successful anchor so duds don't poison future mounts.
Picks variety: similar-artist fetch raised from 12 to 25 (same
getArtistInfo call, larger response — Last.fm typically returns up to
~50). Per-server ring buffer of the last 30 shown album ids; per-similar
-artist album choice prefers an album not in that buffer, falling back
to any album when the artist's whole catalogue is stale so the slot is
never lost.
Pool cap raised 12 -> 20 to give the cooldown buffer room to breathe in
libraries with varied listening history.
Storage: legacy `psysonic_because_anchor:` single-id keys from the
round-robin era are stripped on module load (one-shot localStorage
sweep, the new `..._anchor_history:` prefix has a different colon
position so no false matches).
API budget unchanged in the hot path: 1 getArtistInfo + 6 getArtist
per Home mount. Worst case (3 dud anchors, 4th succeeds) is 4
getArtistInfo + 6 getArtist.