Commit Graph
100 Commits
Author SHA1 Message Date
vicrodh 7eb8fc1533 fix(snap): bump source-tag to v1.2.8 to match version field
The snapcraft.yaml `version:` field was bumped to 1.2.8 but
`source-tag:` under parts.qbz still pointed to v1.2.0. The Snap
Store build farm clones the repo and runs `git checkout v1.2.0`,
so every revision since 1.2.1 was published with metadata "1.2.X"
but a binary compiled from v1.2.0 source — silent regression
visible only when the user opens the app and sees the wrong
version reported.

Affects 8 releases (1.2.1 through 1.2.8). Fixes #361.

cc
2026-04-28 09:21:43 -06:00
vicrodh f533660a43 fix(tray): rebuild Linux tray icon as monochrome multi-size pixmaps (#362)
The previous tray-light.png / tray-dark.png were 128x128 RGBA glyphs with a
black radial background. GNOME's StatusNotifierItem path expects monochrome
silhouettes around 22px base size and was rendering ours as the "three dots"
fallback.

Regenerate from icons/icon-symbolic.svg using Inkscape with a corrected
square viewBox (149 1 944 944, centred on the path bbox) and emit four
sizes per theme (22/32/44/64). decode_tray_icons() now returns Vec<Icon>
so ksni can offer the panel the best match for its bar height and HiDPI.

cc
2026-04-27 22:52:01 -06:00
vicrodh 5bfb748876 feat(session): opt-in resume playback position on session restore (#317)
Adds a separate "Resume playback position" toggle in Settings >
Playback. Default is OFF — opening qbz the next morning lands on a
clean 0:00 of the saved track, which is the common preference. Users
who do want bookmark-style resume turn the toggle on.

Independent of "Restore session on startup" in the UI (top-level
toggle, not a sub-setting). The seek only applies when both are on:
without persistSession there's no saved track to resume to, so the
flag is inert. Keeping the toggles separate matches the Settings UX
elsewhere and lets users flip persistence on without inheriting the
old #317 behavior we never had.

Backend
-------
- New `PlaybackPreferences::resume_playback_position: bool` (default
  false). Migration adds the column to the existing
  `playback_preferences.db` and the default row gets 0; existing
  installs upgrade transparently.
- Setter `set_resume_playback_position(bool)` on both
  `PlaybackPreferencesStore` and the wrapping `State`.
- Tauri command `v2_set_resume_playback_position` registered in
  lib.rs invoke handler. Get-side already reads the new field via
  the existing `v2_get_playback_preferences`.

Frontend
--------
- `playbackPreferencesStore.ts` — new field on the type and cache,
  new `setResumePlaybackPosition()` exported.
- `SettingsView.svelte` — separate toggle row right below
  "Restore session on startup" (matches the existing Settings rhythm).
- `playerStore.ts` — `pendingSessionRestore` extended with optional
  `positionSecs`. When present, after the restored track loads, the
  position is queued through the existing `pendingSeekPosition` /
  `flushPendingSeek` path so the seek runs against an already-loaded
  stream and respects the in-flight guard. Skip the queue when the
  saved position is at or near 0 (the load already starts there).
- `routes/+page.svelte` — at session restore, reads
  `getCachedPreferences().resume_playback_position` and forwards
  `session.current_position_secs` only when the user opted in. The
  field has been persisted in the schema since v1.2.5 — what changes
  is whether we read it back.

i18n
----
- New keys in 5 locales: `settings.playback.resumePlaybackPosition` /
  `…Desc`.
- `persistSessionDesc` updated everywhere — it used to claim the
  feature restored playback position, which was always misleading
  (the code restored the track but paused at 0:00). The text now
  matches what the toggle does on its own: queue + view, paused at
  0:00. Resume-to-position becomes the explicit ask of the new toggle.

Tests
-----
- Backend: 196 tests pass. Frontend: 0 TS errors.

cc
2026-04-27 21:29:29 -06:00
vicrodh 66f10ebc7a chore(svelte): remove dead CSS selectors flagged by vite-plugin-svelte
Eleven selectors had been left behind after various refactors when the
markup migrated to other classes. Vite-plugin-svelte logs them as
"Unused CSS selector" on every dev reload, drowning useful warnings
in noise.

Removed:

  src/lib/components/Sidebar.svelte
    .section-header-btn, .section-header-btn:hover,
    .section-header-btn .section-header

  src/lib/components/views/HomeView.svelte
    .section, .section-header, .section-header h2,
    .track-list, .track-list.compact,
    .track-list.compact :global(.track-row.compact)

  src/lib/components/views/ForYouTab.svelte
    .track-list, .track-list.compact

Each removal was verified against the template — none of the removed
selectors had a corresponding `class="..."` reference in the file.

npm run check: 41 → 30 warnings (the remaining ones are unrelated:
line-clamp / app-region / ARIA hints).

cc
2026-04-27 20:27:21 -06:00
vicrodh 65cb9ad7c4 feat(track): propagate version through queue builders and MPRIS (#360)
The previous two commits added the version field but several builder
helpers in routes/+page.svelte, miniplayer/+page.svelte, and view-
specific local Track interfaces still dropped it on the way to the
backend, so the on-screen rendering went back to the bare title for
queues built from anywhere except album/playlist detail.

Builders updated to pass version
--------------------------------
~50 inline `title: trk.title` / `title: track.title` assignments where
the destination is a BackendQueueTrack (sent to v2_replace_queue,
v2_add_to_queue_*, mixtape enqueue paths, infinite-radio start, etc.)
across:

  - src/routes/+page.svelte (album play, mixtape, search-result play,
    favorites/playlist play, history mapping, queue-from-session
    restore, infinite-radio resolution, dynamic suggest, etc.)
  - src/routes/miniplayer/+page.svelte (queue snapshot)
  - AlbumDetailView, ArtistDetailView, FavoritesView, FavQView,
    TopQView, LabelView, PurchasesView, PurchaseAlbumDetailView,
    SearchView, DynamicSuggestView, WeeklySuggestView
  - playbackService.ts (cast metadata, MPRIS metadata - via
    formatTrackTitle so MPRIS shows the full title with edition)
  - lyricsStore.ts (track context for lyrics queries)
  - immersive/panels/CoverflowPanel.svelte (rendered title in
    coverflow card uses formatTrackTitle directly)

Local Track interfaces extended with `version?: string | null`
--------------------------------------------------------------
- ArtistDetailView: Track and DisplayTrack interfaces
- LabelView: Track interface
- SearchView: Track interface
- FavoritesView: FavoriteTrack interface
- FavQView: FavTrack interface
- DynamicSuggestTrack (in src/lib/types/dynamicSuggest.ts)
- PurchasedTrack (in src/lib/types/purchases.ts)

Reverted (the auto-insert script touched these but the destination
type is not a track):
- AddToMixtapeItem: it's a wrapper for both album and track ops; the
  underlying track keeps version through other paths
- v2_set_media_metadata invoke and v2_cache_track_for_offline invoke:
  the backend command shapes don't have a version arg; can be added
  later if MPRIS/cache need to surface it independently
- Album-level constructions in some search paths: those build an
  Album (no edition concept at album-level for the search row)

Frontend npm run check: 0 errors. Backend cargo test: 196 passed.

cc
2026-04-27 20:20:52 -06:00
vicrodh 0129518d8f feat(track): propagate version through remaining UI surfaces (#360)
Second batch of formatTrackTitle adoption — covers all the components
that were rendering bare {track.title} after the core renderers in the
previous commit:

Modals
  - TrackInfoModal: heading
  - TrackReplacementModal: track-title row
  - AlbumCreditsModal: track-title cell

Views
  - HomeView, ForYouTab: TrackRow title prop + version propagation in
    toDisplayTrack()/buildContinueQueueTracks()
  - ArtistDetailView, LabelView, PurchasesView, PurchaseAlbumDetailView:
    track-title row in their list renderings
  - WeeklySuggestView, DynamicSuggestView, FavQView, TopQView,
    LocalLibraryView: TrackRow title prop
  - FavoritesView: getFavoriteTrackTitle accessor passed to
    VirtualizedTrackList

Immersive
  - immersive/panels/QueuePanel: now-playing + upcoming track titles
  - immersive/panels/TrackInfoPanel: heading

Search
  - PlaylistSuggestions: track-title row

Each file gains an `import { formatTrackTitle } from '$lib/utils/trackTitle'`
and the renderer that used to emit {track.title} now calls
{formatTrackTitle(track)} so remix/reissue editions show their subtitle
parenthesized, matching the mobile app.

Frontend npm run check: 0 errors. Backend cargo test: 196 passed.

The remaining `title: track.title,` assignments in builder helpers
(routes/+page.svelte, miniplayer/+page.svelte, favorites builders,
etc.) where version is not yet appended will be cleaned up separately;
they construct BackendQueueTrack from sources that may or may not
carry version, and adding the field requires per-call inspection of
the source type. The on-screen rendering bug from #360 is fixed for
all the views and modals listed above.

cc
2026-04-27 19:07:59 -06:00
vicrodh 6ff7aab61e feat(track): show Qobuz subtitle/edition (version) in core renderers (#360)
Qobuz returns a separate `version` field for tracks belonging to remix
albums, reissues, and deluxe editions (e.g. "Player's Ball Mix",
"Nine Inch Noize Version", "Remastered 2024"). The mobile app shows it
parenthesized after the title so remix albums are distinguishable from
originals; qbz only showed the bare title, making remix albums
impossible to navigate (issue #360).

Backend
-------
- Add `version: Option<String>` to qbz_models::Track (the V2 model
  consumed by qbz-qobuz/qbz-library) and to api::models::Track (the
  legacy struct still used by some V2 command paths).
- Add `version: Option<String>` to qbz_models::QueueTrack so the field
  travels through the local queue, persistence, QConnect remote queue,
  and frontend snapshots without being lost in conversion.
- Propagate `version` in every Track→QueueTrack converter:
  qconnect::model_track_to_core_queue_track,
  link_resolver::track_to_queue_track_from_api,
  mixtape::enqueue::track_to_queue_track_from_api and the inline
  builders. Plex/local builders set version=None (no edition concept
  outside Qobuz).
- V2QueueTrack (the wire shape exposed to the frontend) gains the
  field with serde default so older clients keep deserializing.

Frontend
--------
- New `formatTrackTitle(track)` helper in src/lib/utils/trackTitle.ts:
  returns `title` when version is empty, else `title (version)`.
- Add `version?: string | null` to QobuzTrack, Track (UI), PlaylistTrack,
  DisplayTrack, BackendQueueTrack, QueueTrack (UI), and PlayingTrack
  so the field flows from the wire to the renderer.
- Propagate `version` through the four buildQueueTrackFrom* builders
  in trackActions.ts and through the QobuzAlbum→AlbumDetail adapter.
- Use formatTrackTitle in the high-traffic renderers:
  - NowPlayingBar (currentTrack derived in routes/+page.svelte)
  - QueuePanel: now-playing label, upcoming-track rows, history rows
  - VirtualizedTrackList: default getTrackTitle accessor
  - AlbumDetailView: TrackRow title binding
  - PlaylistDetailView: TrackRow title binding
  - SearchView: popular-track card label and the two track-list
    rows (Tracks tab + cross-search "All" view)

Tests
-----
- 196 lib tests pass (no behavior change for the no-version path).
- npm run check: 0 errors.

Follow-up: more views still pass `{track.title}` directly (HomeView,
ArtistDetailView, FavoritesView, ForYouTab, MixtapeCollectionDetailView,
LocalLibraryView, the various Suggest/Top/Label views, the immersive
panels, the miniplayer surface, and the Track*Modal stack). They all
render UI Track-shaped objects that now carry version, so each is a
mechanical "import formatTrackTitle + replace {track.title} with
{formatTrackTitle(track)}". Doing them in a follow-up commit to keep
this one reviewable.

cc
2026-04-27 18:45:17 -06:00
vicrodh 532ec89095 refactor(qconnect): extract QconnectServiceState to service.rs
The final extraction of Alcance A. Move the public service facade
(QconnectServiceState struct + impl + Default impl, plus the spawned
event-loop task, the renderer/queue snapshot queries, the controller-
handoff helpers, send_command/send_renderer_report, the deferred
renderer-join plumbing, and the bootstrap_remote_presence pending-
action machinery) from mod.rs into a dedicated service submodule.

Items moved:
- struct QconnectRuntime, QconnectServiceInner
- pub struct QconnectServiceState (re-exported via `pub use`)
- impl QconnectServiceState (~50 methods) and impl Default
- bootstrap_remote_presence, deferred_renderer_join,
  clear_pending_if_matches
- queue_payload_track_preview, update_lifecycle_state_if_running,
  emit_qconnect_diagnostic (private helpers used only by the service)
- Constants: JOIN_SESSION_REASON_CONTROLLER_REQUEST,
  QCONNECT_PLAY_TRACK_HANDOFF_WAIT_MS,
  QCONNECT_PLAY_TRACK_HANDOFF_POLL_MS

All methods on QconnectServiceState are pub(super) so the commands
submodule (which was already extracted) can dispatch through them.
The Default impl uses plain `fn default()` (visibility qualifiers are
not permitted in trait impls).

mod.rs now contains only the cross-submodule glue (~140 lines):
shared constants (PLAYING_STATE_*, AUDIO_QUALITY_*, BUFFER_STATE_OK,
DEFAULT_QCONNECT_CHANNEL_COUNT, QCONNECT_REMOTE_QUEUE_SOURCE), the
QconnectRemoteSyncState accumulator, qconnect_now_ms,
qconnect_repeat_mode_from_loop_mode, normalize_volume_to_fraction,
model_track_to_core_queue_track, and the module declarations.

session.rs and commands.rs imports re-pathed to point at submodules
directly (super::transport::*, super::queue_resolution::*,
super::session::*, super::service::*) instead of going through
re-exports in mod.rs.

mod.rs: 1820 → 142 lines (-92%). service.rs: 1689 lines.

Final layout:

  src-tauri/src/qconnect/
  ├── mod.rs              ( 142)  module glue
  ├── types.rs            ( 273)  wire types, payloads
  ├── session.rs          ( 374)  topology, renderer snapshots
  ├── queue_resolution.rs ( 481)  cursors, shuffle order
  ├── corebridge.rs       ( 566)  apply_renderer_command, materialize
  ├── track_loading.rs    ( 337)  streaming, FLAC probe, dedup
  ├── event_sink.rs       ( 461)  QconnectEventSink + dispatch
  ├── transport.rs        ( 386)  credentials, device identity
  ├── service.rs          (1689)  QconnectServiceState facade
  ├── commands.rs         ( 849)  Tauri invoke handlers
  └── tests.rs            (1044)  37 unit tests

From 6371 lines in 1 monolith → 6602 lines across 11 focused files,
each with a single clear responsibility. mod.rs went 6371 → 142 lines
(-98%). All 37 tests pass. Alcance A complete; the codebase is ready
for unified-binary Phase 0 to resume.

cc
2026-04-27 18:00:43 -06:00
vicrodh 49f56d563e refactor(qconnect): extract test module into qconnect/tests.rs
Move the 37 unit tests (1044 lines, all #[cfg(test)] inside the old
mod tests {} block in mod.rs) into a dedicated tests.rs submodule.
mod.rs keeps only the declaration `#[cfg(test)] mod tests;`.

Test imports are unchanged — they still use `super::*` paths into the
focused submodules established in steps 2-9, which is exactly the
intended layout.

This concludes Alcance A of the qconnect refactor. Full layout:

  src-tauri/src/qconnect/
  ├── mod.rs              (1820 lines) — QconnectServiceState impl,
  │                                       glue, internal structs
  ├── types.rs            ( 273 lines) — wire types, request payloads
  ├── session.rs          ( 374 lines) — topology, renderer snapshots
  ├── queue_resolution.rs ( 481 lines) — cursors, indices, shuffle order
  ├── corebridge.rs       ( 566 lines) — apply_renderer_command,
  │                                       materialize, projection
  ├── track_loading.rs    ( 337 lines) — streaming, FLAC probe, dedup
  ├── event_sink.rs       ( 461 lines) — QconnectEventSink + dispatch
  ├── transport.rs        ( 386 lines) — credentials, device identity
  ├── commands.rs         ( 849 lines) — Tauri invoke handlers + report
  │                                       helpers
  └── tests.rs            (1044 lines) — 37 unit tests

From 6371 lines in 1 monolith → 7591 lines across 10 focused files
with clear responsibility boundaries. mod.rs went 6371 → 1820 (-71%).
Each submodule is independently navigable; visibility is constrained
via pub(super) so re-exports stay intentional. All 37 tests pass.

cc
2026-04-27 17:30:08 -06:00
vicrodh 7bd5562203 refactor(qconnect): extract Tauri commands to commands.rs
Move all 30 #[tauri::command] handlers (registered in lib.rs's
invoke_handler!) plus the renderer-report helpers they own into a
dedicated commands submodule:

Tauri commands (re-exported via `pub use commands::*` so lib.rs and
external callers keep their import paths unchanged):
- v2_qconnect_connect, _disconnect, _status, _send_command
- v2_qconnect_evaluate_queue_admission,
  _send_command_with_admission, _join_session, _set_player_state
- v2_qconnect_toggle_play_if_remote, _play_track_if_remote,
  _skip_next_if_remote, _skip_previous_if_remote,
  _set_volume_if_remote, _mute_if_remote, _stop_if_remote,
  _toggle_shuffle_if_remote, _cycle_repeat_if_remote,
  _set_autoplay_mode_if_remote, _autoplay_load_tracks_if_remote
- v2_qconnect_set_active_renderer, _set_volume, _set_loop_mode,
  _mute_volume, _set_max_audio_quality, _ask_for_renderer_state
- v2_qconnect_queue_snapshot, _renderer_snapshot, _session_snapshot
- v2_qconnect_report_playback_state, _report_volume
- v2_qconnect_get_device_name, _set_device_name
- v2_get_hostname

Renderer-report helpers moved alongside the report command:
- classify_qconnect_audio_quality,
  build_qconnect_file_audio_quality_snapshot,
  resolve_active_playback_audio_quality
- should_skip_renderer_report_due_to_stale_snapshot,
  determine_queue_lookup_report_strategy,
  should_report_queue_item_ids_for_renderer_state

mod.rs deps that commands.rs reaches via super:: are now pub(super):
- BUFFER_STATE_OK, DEFAULT_QCONNECT_CHANNEL_COUNT
- AUDIO_QUALITY_UNKNOWN/CD/HIRES_LEVEL1/LEVEL3 (the rest were
  already pub(super))
- QconnectServiceState.custom_device_name field

Test imports re-pathed to super::commands::* for the helpers that
moved with the report command.

Pure relocation. mod.rs: 3673 → 2865 lines. commands.rs: 847 lines.
All 37 qconnect tests pass.

cc
2026-04-27 17:24:22 -06:00
vicrodh b4a32aec5d refactor(qconnect): fix test module imports broken by submodule split
Steps 4-8 moved items into focused submodules but only verified `cargo
check` (lib only) — tests under #[cfg(test)] in mod.rs continued
referring to items via plain `super::FunctionName`, which only resolved
when the items were still in mod.rs.

This commit re-paths the test imports to point at their actual
submodules:

- decode_hex_channel, parse_subscribe_channels, default_qconnect_device_info
  → super::transport::*
- find_unique_renderer_id, refresh_local_renderer_id,
  QconnectFileAudioQualitySnapshot → super::session::*
- resolve_controller_queue_item_from_snapshots,
  resolve_queue_item_ids_from_queue_state, QconnectRemoteSkipDirection,
  resolve_core_shuffle_order, QconnectControllerQueueItemResolution
  → super::queue_resolution::*
- build_session_renderer_snapshot → super::session::*
- should_reload_remote_track → super::track_loading::*
- queue_state_needs_materialization → super::corebridge::*

Promoted decode_hex_channel and parse_subscribe_channels to pub(super)
in transport.rs, and should_reload_remote_track to pub(super) in
track_loading.rs so the tests can reach them across the submodule
boundary.

All 37 qconnect tests pass.

cc
2026-04-27 17:04:08 -06:00
vicrodh c9285a33e9 refactor(qconnect): extract device identity and transport config to transport.rs
Move 14 items that own QConnect transport setup, device identity, and
credential discovery into a dedicated transport submodule:

Device identity (pub(super)):
- default_qconnect_device_info / default_qconnect_device_info_with_name
- resolve_qconnect_device_uuid (with QCONNECT_DEVICE_UUID OnceLock)
- resolve_system_hostname, resolve_default_qconnect_device_name

Custom-device-name persistence (pub(super)):
- load_persisted_device_name, persist_device_name
  (qconnect_settings_db_path stays private to the submodule)

Transport config + credentials (pub(super) + private):
- resolve_transport_config (handles env-var fallbacks, qws/createToken
  auto-discovery, subscribe-channel parsing)
- fetch_qconnect_transport_credentials (private)
- normalize_opt_string, parse_subscribe_channels, decode_hex_channel
  (private)

Diagnostics (pub(super)):
- hex_preview (used by transport-event debug logs in mod.rs)

Constants moved to transport.rs (private to the submodule):
- DEFAULT_QCONNECT_DEVICE_BRAND/MODEL/TYPE/SOFTWARE_PREFIX
- VOLUME_REMOTE_CONTROL_ALLOWED
- QCONNECT_QWS_TOKEN_KIND, QCONNECT_QWS_CREATE_TOKEN_PATH

Constants kept in mod.rs but promoted to pub(super) so transport.rs
can borrow them: AUDIO_QUALITY_MP3, AUDIO_QUALITY_HIRES_LEVEL2.

mod.rs imports OnceLock and Uuid removed (now confined to transport).

Pure relocation. mod.rs: 4022 → 3673 lines. transport.rs: 386 lines.
Total qconnect/ submodules now: 8 (types, session, queue_resolution,
corebridge, track_loading, event_sink, transport, plus residual mod.rs).

cc
2026-04-27 15:48:30 -06:00
vicrodh 8391ef46df refactor(qconnect): extract QconnectEventSink impl to event_sink.rs
Move the Tauri-side event sink (struct TauriQconnectEventSink + the
QconnectEventSink trait impl + the apply_session_management_event
dispatcher) into a dedicated event_sink submodule.

This is the seam between the qconnect-app crate's event stream and
qbz's local state — every WS-driven event (renderer updated, queue
updated, command applied, session-management messages) flows through
on_event. Splitting it out makes the dispatch graph navigable
without scrolling through 4000+ unrelated lines.

apply_session_management_event handles 9 message types:
SESSION_STATE, ADD_RENDERER, UPDATE_RENDERER, REMOVE_RENDERER,
ACTIVE_RENDERER_CHANGED, RENDERER_STATE_UPDATED, VOLUME_CHANGED,
VOLUME_MUTED, MAX_AUDIO_QUALITY_CHANGED, LOOP_MODE_SET — plus three
post-dispatch hooks (apply_remote_loop_mode_to_corebridge,
sync_local_playback_for_renderer_ownership, sync_active_renderer_
projection).

mod.rs:
- TauriQconnectEventSink moved to event_sink module
- qconnect_now_ms marked pub(super) for cross-module access
- async_trait, qconnect_app::{QconnectAppEvent, QconnectEventSink}
  imports removed (now confined to event_sink.rs)
- The session/queue_resolution imports trimmed to only what mod.rs
  itself still references

Pure relocation. mod.rs: 4460 → 4022 lines. event_sink.rs: 425 lines.
Total qconnect/ submodules now: 7 (types, session, queue_resolution,
corebridge, track_loading, event_sink, plus the residual mod.rs).

cc
2026-04-27 15:42:05 -06:00
vicrodh 579aafbe2c refactor(qconnect): extract remote track streaming and load dedup to track_loading.rs
Move 7 items that own remote track loading (HTTP streaming, FLAC probe,
fallback download, and the load-attempt dedup window) into a dedicated
track_loading submodule:

- LOAD_ATTEMPT_DEDUP_WINDOW (5s constant)
- should_reload_remote_track (track-id-only reload predicate)
- load_remote_track_into_player (top-level load with streaming-then-
  download fallback)
- is_recent_load_attempt (dedup check against sync_state)
- ensure_remote_track_loaded (pub(super) — orchestrator called by
  corebridge::apply_renderer_command_to_corebridge and the V2
  play_track_handoff path)
- QconnectRemoteStreamInfo (FLAC probe result)
- stream_remote_track_into_player, probe_remote_stream_info,
  download_and_stream_remote_track, download_remote_audio

corebridge.rs now imports ensure_remote_track_loaded directly from
super::track_loading instead of going through mod.rs.

Pure relocation. mod.rs: 4777 → 4460 lines. track_loading.rs: 337 lines.
Total qconnect/ submodules now: 6 (types, session, queue_resolution,
corebridge, track_loading, plus the residual mod.rs).

cc
2026-04-27 15:05:19 -06:00
vicrodh 69ef5de5a0 refactor(qconnect): extract CoreBridge bridging logic to corebridge.rs
Move 6 functions that bridge cloud-emitted QConnect events to the
local CoreBridge audio engine into a dedicated corebridge submodule:

- queue_state_needs_materialization (dedup comparator)
- apply_remote_loop_mode_to_corebridge
- apply_renderer_command_to_corebridge (SetState/SetVolume/MuteVolume/
  SetLoopMode/SetActive handler — ~180 lines, the busiest function)
- sync_corebridge_remote_shuffle_projection (shuffle projection with
  identity-shuffle deferral; remains private to corebridge)
- materialize_remote_queue_to_corebridge (queue replication, ~210 lines)
- align_corebridge_queue_cursor

mod.rs deps that corebridge.rs reaches via super:: are now pub(super):
- PLAYING_STATE_PLAYING/PAUSED/STOPPED/UNKNOWN constants
- normalize_volume_to_fraction, qconnect_repeat_mode_from_loop_mode,
  model_track_to_core_queue_track
- ensure_remote_track_loaded (still in mod.rs, will move to
  track_loading.rs in a follow-up step)

corebridge.rs imports queue_resolution::* directly for the cursor and
order helpers, and session::is_peer_renderer_active for the echo-reset
gate.

Pure relocation. mod.rs: 5304 → 4777 lines. corebridge.rs: 565 lines.

cc
2026-04-27 15:02:14 -06:00
vicrodh 7ea9511c26 refactor(qconnect): extract queue cursor and order resolution to queue_resolution.rs
Move 18 items that own the protocol-side queue/cursor/order math from
mod.rs into a dedicated queue_resolution submodule:

Types (pub(super)):
- QconnectOrderedQueueCursor — Queue/Autoplay cursor abstraction
- QconnectRemoteSkipDirection — controller next/previous direction
- QconnectControllerQueueItemResolution — skip resolution outcome

Helpers (pub(super) when used by other submodules, fn otherwise):
- is_valid_ordered_queue_shuffle_order
- ordered_queue_cursors, queue_item_track_id_for_cursor,
  normalized_queue_item_id_for_cursor
- find_cursor_index_by_queue_item_id, find_cursor_index_by_track_id,
  find_cursor_index_by_track_id_before
- resolve_current_cursor_index_from_snapshots,
  resolve_controller_queue_item_from_snapshots,
  resolve_queue_item_ids_from_queue_state
- dedupe_track_ids
- resolve_remote_start_index, resolve_core_shuffle_order
- is_cloud_placeholder_current_queue_item,
  normalize_current_queue_item_id_from_queue_state

session.rs now imports cursor helpers directly from
super::queue_resolution::* instead of going through mod.rs.

Pure relocation. mod.rs: 5766 → 5297 lines. queue_resolution.rs: 460 lines.

cc
2026-04-27 14:57:54 -06:00
vicrodh a608ba2e06 refactor(qconnect): extract session topology and renderer snapshots to session.rs
Move 16 items from mod.rs to a dedicated session submodule:

Types:
- QconnectSessionState (pub) — session topology surfaced to frontend
- QconnectRendererInfo (pub) — renderer device descriptor
- QconnectSessionRendererState (pub(crate)) — per-renderer cached state
- QconnectFileAudioQualitySnapshot (pub(crate)) — audio quality dedup
- QconnectRendererReportDebugEvent (pub(crate)) — renderer report telemetry

Helpers (all pub(super)):
- refresh_local_renderer_id, find_unique_renderer_id
- normalize_active_renderer_id, is_peer_renderer_active, is_local_renderer_active
- sync_session_renderer_active_flags, ensure_session_renderer_state
- build_session_renderer_snapshot, build_effective_renderer_snapshot
- build_visible_queue_projection, queue_item_snapshot_for_cursor
- cache_renderer_snapshot

mod.rs deps that session.rs reaches via super:: are now pub(super):
- QconnectRemoteSyncState and all its fields
- QconnectOrderedQueueCursor
- ordered_queue_cursors, find_cursor_index_by_queue_item_id,
  find_cursor_index_by_track_id, normalize_current_queue_item_id_from_queue_state
- default_qconnect_device_info, resolve_qconnect_device_uuid

Pure relocation. mod.rs: 6109 → 5766 lines. session.rs: 372 lines.

cc
2026-04-27 14:51:33 -06:00
vicrodh e5c0261158 refactor(qconnect): extract wire types and request payloads to types.rs
Move 22 public types (QconnectConnectOptions, QconnectLifecycleState,
QconnectConnectionStatus, QconnectVisibleQueueProjection, the 20-variant
QconnectOutboundCommandType + 5 request payload structs, the 4 admission/
handoff types, the 8 device/session/queue payloads) into a dedicated
types.rs submodule. mod.rs re-exports via `pub use types::*` so external
callers (lib.rs invoke handlers, commands_v2/queue.rs) keep their import
paths.

Pure data and enum-mapping impls only. Two helper methods on
QconnectOutboundCommandType (to_queue_command_type,
requires_remote_queue_admission) become pub(super) since they are only
used from within the qconnect module tree.

mod.rs: 6371 → 6109 lines. types.rs: 273 lines.

cc
2026-04-27 14:17:19 -06:00
vicrodh fc5cd6efc7 refactor(qconnect): move qconnect_service.rs to qconnect/mod.rs
Pure rename to enable subsequent splits into focused submodules. No
behavior change: 34 invoke handler refs in lib.rs and 2 use statements
in commands_v2/queue.rs updated from qconnect_service:: to qconnect::.

Foundation for the qconnect_service.rs → qconnect/ module split: the
file is 6371 lines and mixes 12 distinct responsibilities (types,
session topology, event sink, queue resolution, corebridge bridging,
track loading, transport, Tauri commands, etc). Splitting into focused
submodules makes the area navigable for both authors and reviewers,
enables independent reasoning per concern, and creates a clean target
for the eventual qbz-qconnect-proto crate extraction.

cc
2026-04-27 14:14:12 -06:00
vicrodh 147bcbd715 fix(qconnect): eliminate first-track hiccup, prev/next bouncing, and shuffle drift
Bundle of related fixes addressing QConnect echo handling and shuffle
authority. All four converge on the same architectural principle:
qbz must not invent state when the cloud is the source of truth.

1. Remove should_force_remote_track_restart and its echo-driven reload

   The function fired whenever the cloud echoed SetState with
   target_position_ms=0 for the same track currently playing. The cloud
   re-emits this routinely on secondary field changes (next_track
   corrections, queue_item_id refresh) — every echo triggered a
   load_remote_track_into_player that interrupted in-progress audio.

   Track-change cases continue to load via align_corebridge_queue_cursor
   plus ensure_remote_track_loaded.

2. Reject echo seeks in apply_renderer_command_to_corebridge

   Authoritative seek-to-zero is only honored when the command targets
   a different track, or when local position is already near zero. If
   command.current_track matches playback_state.track_id and target<=1s
   while local_pos>2s, the seek is treated as echo and skipped. The
   prior 2s threshold was insufficient to absorb cloud round-trip during
   queue changes.

3. Drop has_loaded_audio gate from should_reload_remote_track

   playback_state.track_id is updated by the audio thread only after
   engine.append(source) succeeds. During the 1-3s buffer/decode window
   of an initial load, has_loaded_audio is false even though loading is
   in flight — the prior gate caused ensure_remote_track_loaded to
   redundantly re-trigger load on every echo SetState arriving in that
   window. This was the residual first-track hiccup.

4. Add load-attempt dedup across V2 play and ensure_remote_track_loaded

   New sync_state.last_load_attempt: Option<(track_id, Instant)> with a
   5s window. Two write sites:
     - play_remote_renderer_track_if_active when active_renderer_is_local
       (V2 frontend just dispatched a local play)
     - ensure_remote_track_loaded when it triggers its own load
   Read site: ensure_remote_track_loaded skips if last attempt for the
   same track was within the window. Eliminates redundant loads when an
   echo SetState arrives while a local play is still buffering.

5. Defer engine shuffle activation until cloud sends a valid order

   The cloud broadcasts shuffle toggles in two stages: first a
   QueueUpdated with shuffle_mode=true and shuffle_order=null, then
   ~400ms later a QueueUpdated with the computed shuffle_order. The
   prior code applied set_queue_with_order with shuffle_enabled=true on
   the first event, falling through to set_identity_shuffle_order_internal
   — qbz inventing a sequence (0,1,2,...) that diverges from the order
   the cloud is about to authorize. If the second event was delayed,
   dropped, or raced against the first, qbz drifted from peer clients
   (the long-standing "doble shuffle" symptom).

   Now: effective_shuffle_enabled = shuffle_mode && core_shuffle_order.
   is_some(). When the order is absent, the engine stays unshuffled and
   waits for the second QueueUpdated. sync_corebridge_remote_shuffle_
   projection short-circuits identically. Updated diagnostic log
   surfaces remote_shuffle_mode, shuffle_order_present, and engine_
   shuffle_enabled separately for future triage.

Tests: removed should_force_remote_track_restart test, simplified
should_reload_remote_track test to reflect the track-id-only contract.

cc
2026-04-27 14:02:58 -06:00
vicrodh dee987c1aa fix(player): gate gapless transition on prefetch attempted for current track
The isGaplessTransition predicate fired on every external track change
(QConnect renderer command, MPRIS, etc.) when qbz was the local
renderer, mis-classifying remote-driven track changes as gapless
transitions. The handler then ran onGaplessTransition every backend
tick (~1Hz) without updating currentTrack, leaving the UI stale and
spamming v2_next_track.

Added gaplessAttemptedForCurrent gate captured before the existing
null-out: only treat the event as a gapless transition when
gaplessAttemptTrackId matches the track currentTrack actually believes
is playing. External track changes fall through to the
"track changed externally" block which updates currentTrack properly.

cc
2026-04-27 14:02:32 -06:00
vicrodh 89651cae60 Merge chore/internal/orphan-files-deletion into pre-release 2026-04-26 23:15:34 -06:00
vicrodh 6cecb22cb1 chore(network): delete dead pub mod commands block in network/mod.rs
The inner `pub mod commands` block in network/mod.rs hosted three
unregistered Tauri commands (get_network_mounts_cmd,
check_mount_accessible, check_network_paths_batch) and was uncalled.
The non-command helpers in the parent module (get_network_mounts,
check_mount_accessibility, find_mount_for_path, parse_mount_info)
are also no longer referenced now that the commands are gone, but
they remain pub for potential future use without warnings.

- Remove the inner `pub mod commands { ... }` block.
- Drop the now-unused std::collections::HashMap import.

cc
2026-04-26 22:50:38 -06:00
vicrodh 2345bf7849 chore(commands): delete legacy commands module and inline helpers
src-tauri/src/commands/user_session.rs hosted three unregistered
#[tauri::command] entries (activate_user_session, deactivate_user_session,
get_last_user_id) plus two non-command helpers (init_type_alias_state,
teardown_type_alias_state) consumed only by session_lifecycle.rs.

- Move init_type_alias_state and teardown_type_alias_state into
  session_lifecycle.rs (their only caller) as plain pub helpers.
- Delete src-tauri/src/commands/user_session.rs and
  src-tauri/src/commands/mod.rs.
- Remove pub mod commands; from lib.rs — the legacy commands
  namespace is now empty.
- Refresh the activate_session doc comment to match the new layout.

cc
2026-04-26 22:47:11 -06:00
vicrodh 3b6cb3c51f chore(library): delete dead library/commands.rs
The 1,375-line library/commands.rs hosted only unregistered Tauri
commands (54 of them) plus a path-compat pub use of state types.
Every #[tauri::command] in the file is V2-replaced and uncalled.
The state-types pub use was the only external surface and is
already exported canonically from library/mod.rs.

- Delete src-tauri/src/library/commands.rs entirely
- Drop pub mod commands; from library/mod.rs
- Update session_lifecycle.rs to use crate::library::LibraryState
  (the canonical path) instead of crate::library::commands::LibraryState
- Refresh the doc comment in library/state.rs to reflect the
  post-cleanup layout

cc
2026-04-26 22:44:34 -06:00
vicrodh df1583a727 chore(offline_cache): extract purge helper and delete dead commands.rs
All 17 #[tauri::command] entries in offline_cache/commands.rs are
unregistered (V2 owns the live cache commands) and uncalled. The only
surviving consumer was session_lifecycle.rs reaching for
purge_all_cached_files (a non-command helper).

- Move purge_all_cached_files to a new offline_cache/purge.rs module
  (drops the LibraryState path through library::commands; uses the
  canonical crate::library::LibraryState directly)
- Delete offline_cache/commands.rs along with its dead siblings:
  cache_track_for_offline + post_process_cached_track,
  is_track_cached, get_cached_track_path, get_cached_track,
  get_cached_tracks, get_offline_cache_stats, remove_cached_track,
  clear_offline_cache + cleanup_empty_folder, set_offline_cache_limit
  + evict_if_needed, open_offline_cache_folder, open_album_folder,
  open_track_folder, check_offline_root_mounted, validate_offline_path,
  move_offline_cache_to_path, detect_legacy_cached_files (the command
  shim — the helper of the same name lives in migration.rs and is
  still consumed by V2), start_legacy_migration,
  sync_offline_cache_to_library + SyncResult.
- Update session_lifecycle.rs to import from offline_cache::purge.

cc
2026-04-26 22:42:28 -06:00
vicrodh 1a138d8e2f chore(reco_store): rename commands.rs to helpers.rs and drop dead commands
The reco_store/commands.rs module no longer hosts any registered Tauri
commands. Its surviving content is purely helper logic consumed by V2
home recommendations in commands_v2/library.rs.

- Rename reco_store/commands.rs -> reco_store/helpers.rs
- Drop the unregistered/uncalled #[tauri::command] entries
  (reco_log_event, reco_train_scores, get_playlist_suggestions,
  reco_backfill_genres, reco_needs_genre_backfill) and their dead
  scoring helpers (build_scores, build_track_entries, decay_factor,
  current_timestamp, etc.) along with RecoTrainStats / RecoScoreCounts
  / BackfillStats structs that only those commands referenced.
- Update commands_v2/library.rs to import from
  crate::reco_store::helpers::* (still uses get_home_seeds_internal,
  resolve_albums, resolve_tracks, resolve_artists, album_to_card_meta).

cc
2026-04-26 22:39:05 -06:00
vicrodh 3f819d71df chore(cast): delete dead cast and dlna commands modules
The Tauri commands in cast/commands.rs and cast/dlna/commands.rs are
unregistered (handler list is V2-only) and uncalled. The CastState /
DlnaState path-compat re-exports are also unused (canonical paths are
crate::cast::CastState and crate::cast::dlna::DlnaState). Remove the
files entirely and drop pub mod declarations.

cc
2026-04-26 22:35:14 -06:00
vicrodh c57acbda4c Merge refactor/internal/api-server-v2-migration into pre-release 2026-04-26 22:26:02 -06:00
vicrodh dc16e71617 chore(legacy): delete src-tauri/src/commands/playback.rs
The last consumer (api_server REST handlers) was migrated to
commands_v2::v2_play_track / v2_seek / v2_set_volume in the previous
commit, so the file's three remaining #[tauri::command] functions
(play_track, seek, set_volume) plus the unregistered prefetch_track,
get_playback_state, and all helper symbols (parse_quality,
limit_quality_for_device, PlayTrackResult, StreamInfo, download_audio,
get_stream_info, extract_audio_format_from_header, download_and_stream,
spawn_prefetch, prefetch constants, PREFETCH_SEMAPHORE) had zero
remaining callers.

Drops the `pub mod playback;` and `pub use playback::*;` lines from
commands/mod.rs. Only user_session.rs remains in the legacy commands
module.

cc
2026-04-26 22:00:06 -06:00
vicrodh 2d60f50140 refactor(api): migrate api_server playback to V2 commands
Switches the REST endpoints serving the PWA remote control from the
legacy commands::playback::* helpers to their V2 equivalents:

- play_queue_track / play_queue_index / play_album:
    commands::playback::play_track -> commands_v2::v2_play_track
- seek: commands::playback::seek -> commands_v2::v2_seek
- set_volume: commands::playback::set_volume -> commands_v2::v2_set_volume

The HTTP contract (paths, methods, payload shapes, response codes,
emit_playback_update side-effects) is unchanged. Behaviour gains the
V2 features that legacy lacked (CMAF cache hits, ALSA hardware
sample-rate guard, manual-offline-mode awareness) because the PWA
remote now flows through the same playback path as the desktop UI.

Drops the `commands` import from this module (now only `commands_v2`
is used) and adds the State acquisitions needed by v2_play_track:
CoreBridgeState, OfflineState, RuntimeManagerState.

cc
2026-04-26 21:58:16 -06:00
vicrodh 05db9dee67 Merge refactor/internal/inline-library-delegates into pre-release 2026-04-26 21:46:06 -06:00
vicrodh 6ebb234a50 chore(commandRouter): delete dead cmdUpdateAudioSettings function
The function had zero callers across src/. Today's H2 audio rewiring
work updated the invoke names inside cmdUpdateAudioSettings but the
function itself was already unreachable. Remove it.

The remaining commandRouter exports (cmdGetAudioSettings,
cmdGetAvailableBackends, cmdGetDevicesForBackend,
cmdGetHardwareAudioStatus, plus all Playback/Queue helpers) still
have callers, so the file stays.

cc
2026-04-26 21:32:24 -06:00
vicrodh e277862f9d fix(settings): align fetchQobuzArtistImages default convention with consumer
SettingsView read qbz-fetch-artist-images with === 'true' (OFF when key
absent or unrecognised) while LocalLibraryView read with !== 'false'
(ON when key absent or unrecognised). The two files disagreed on
first-run behaviour and on any stored value other than 'true'/'false'.

Switch SettingsView to the !== 'false' convention so the toggle UI
matches what the consumer actually does at fetch time.

cc
2026-04-26 21:31:27 -06:00
vicrodh 078bc98a02 chore(settings): delete orphaned handleShowPartialPlaylistsChange and store import
The Show-Partial-Playlists toggle was intentionally hidden in commit
2579c1990 ("Remove offline playlist integration") because offline
playlists are not yet functional. The handler and its
setShowPartialPlaylists store import were left behind as dead code.
The store function and OfflineSettings.showPartialPlaylists field
remain because Sidebar.svelte and PlaylistManagerView.svelte still
read the setting (defaulting to true).

When offline playlist integration ships, the toggle and handler can
be re-added.

cc
2026-04-26 21:30:27 -06:00
vicrodh f56f146eb8 fix(settings): persist plexManualTokenMode via getUserItem/setUserItem like sibling Plex flags
Toggling Manual Token Mode only updated local $state, so on every
remount of the Settings view the toggle reverted to OFF and hid the
manual-token field. Hydrate from getUserItem and persist via
setUserItem on change, matching the pattern used by plexEnabled,
plexUiCollapsed, and plexMetadataWriteEnabled.

Storage key: qbz-plex-poc-manual-token-mode (string 'true'/'false').

cc
2026-04-26 21:29:30 -06:00
vicrodh fb36aa1bc2 fix(settings): wire RemoteControlSetupGuide to a Show-setup-guide button
The RemoteControlSetupGuide modal was rendered but unreachable: nothing
ever set showRemoteControlGuide to true. Add a Setup-Guide button next
to the existing Regenerate / QR controls in the Remote Control developer
subsection so the onboarding wizard can actually be opened.

The button intentionally is not gated by remoteControlEnabled because
the guide is intended for users who have not yet enabled remote control.

Reuses the pre-existing settings.integrations.remoteControlSetupGuide
i18n key (already present in en/es/de/fr/pt).

cc
2026-04-26 21:28:15 -06:00
vicrodh c647cd0373 refactor(v2): inline offline status, settings, and pending playlist delegates
Inlines bodies of get_offline_status, get_offline_settings,
set_manual_offline, check_network, and get_pending_playlists from
offline::commands directly into the v2 wrappers in commands_v2/library.rs.

Deletes the entire offline::commands submodule (the inlined commands and
the additional dead Tauri command shims that lived alongside them — none
were registered with Tauri or referenced outside the submodule). Prunes
the now-unused tauri AppHandle/Emitter imports from offline/mod.rs.

Step 8 of legacy cleanup, group H.

cc
2026-04-26 20:30:38 -06:00
vicrodh d98d35796e refactor(v2): inline reco_get_home, reco_get_home_ml, reco_get_home_resolved
Inlines bodies of reco_get_home, reco_get_home_ml, and reco_get_home_resolved
from reco_store/commands.rs directly into the v2 wrappers in
commands_v2/library.rs. Promotes their shared helpers
(get_home_seeds_internal, resolve_tracks, resolve_artists,
album_to_card_meta) from private to pub(crate) so the v2 wrappers can
call them without crossing back into a legacy command module.

Deletes the corresponding legacy #[tauri::command] definitions and
prunes now-unused imports from reco_store/commands.rs.

Step 8 of legacy cleanup, group G.

cc
2026-04-26 20:26:19 -06:00
vicrodh 7d18e6d3a8 refactor(v2): inline playlist custom order and local copies delegates
Inlines bodies of playlist_get_custom_order, playlist_has_custom_order,
and playlist_get_tracks_with_local_copies from library/commands.rs
directly into the v2 wrappers in commands_v2/playlists.rs, mapping
LibraryError to RuntimeError::Internal as needed.

Deletes the corresponding legacy #[tauri::command] definitions and
removes their re-exports from library/mod.rs.

Step 8 of legacy cleanup, group F.

cc
2026-04-26 20:21:22 -06:00
vicrodh 7858c16b64 refactor(v2): inline library album, track, and playlist folder delegates
Inlines bodies of library_get_album_tracks, library_get_tracks_by_ids,
and update_playlist_folder from library/commands.rs directly into the
v2 wrappers in commands_v2/library.rs.

Deletes the corresponding legacy #[tauri::command] definitions and
removes their re-exports from library/mod.rs.

Step 8 of legacy cleanup, group E.

cc
2026-04-26 20:18:14 -06:00
vicrodh 44ad3392ef refactor(v2): inline library artwork and thumbnails delegates
Inlines bodies of library_fetch_missing_artwork, library_get_thumbnail,
and library_get_thumbnails_cache_size from library/commands.rs directly
into the v2 wrappers in commands_v2/library.rs.

Deletes the corresponding legacy #[tauri::command] definitions and the
unused legacy library_clear_thumbnails command. Removes their re-exports
from library/mod.rs.

Step 8 of legacy cleanup, group D.

cc
2026-04-26 20:15:52 -06:00
vicrodh 056480a0be refactor(v2): inline library scan progress, stats, and search delegates
Inlines bodies of library_get_scan_progress, library_get_artists,
library_search, and library_get_stats from library/commands.rs directly
into the v2 wrappers in commands_v2/library.rs.

Deletes the corresponding legacy #[tauri::command] definitions and
removes their re-exports from library/mod.rs.

Step 8 of legacy cleanup, group C.

cc
2026-04-26 20:12:24 -06:00
vicrodh 2af3e2059f refactor(v2): inline library folder management delegates
Inlines bodies of library_add_folder, library_cleanup_missing_files,
library_get_folders, and library_get_folders_with_metadata from
library/commands.rs directly into the v2 wrappers in commands_v2/library.rs.

Deletes the corresponding legacy #[tauri::command] definitions and
removes their re-exports from library/mod.rs.

Step 8 of legacy cleanup, group B.

cc
2026-04-26 20:09:09 -06:00
vicrodh 25fffb7fd0 refactor(v2): inline library scan and metadata _impl delegates
Inlines bodies of library_scan_impl, library_stop_scan_impl,
library_scan_folder_impl, library_clear_impl, library_update_album_metadata_impl,
library_write_album_metadata_to_files_impl, and library_refresh_album_metadata_from_files_impl
from library/commands.rs directly into the v2 wrappers in commands_v2/legacy_compat.rs.
Moves their helpers (process_cue_file, apply_sidecar_override_if_present,
compute_track_artist_match, normalize_library_path) alongside the inlined bodies.

Deletes the corresponding legacy #[tauri::command] _impl definitions and
the dead public command siblings (library_scan, library_stop_scan,
library_scan_folder, library_clear, library_update_album_metadata,
library_write_album_metadata_to_files, library_refresh_album_metadata_from_files)
that were only ever called from the deleted _impl variants.
Updates library/mod.rs to drop the corresponding re-exports.

Step 8 of legacy cleanup, group A.

cc
2026-04-26 20:05:16 -06:00
vicrodh 3224a5219b Merge refactor/internal/inline-legacy-compat into pre-release 2026-04-26 19:29:02 -06:00
vicrodh 81461bec32 refactor(v2): inline network check_network_path delegate
Inlines `check_network_path` from crate::network::commands into
commands_v2/legacy_compat.rs. The legacy command was a one-line wrapper
over crate::network::is_network_path; the inlined v2 form calls
is_network_path directly with no behavior change.

Deletes the legacy #[tauri::command] definition in network/mod.rs.
Other tauri commands inside crate::network::commands remain (they have
no v2 delegates and keep being exported as their own legacy commands;
they will be addressed in a separate cleanup step if needed).

No frontend invoke changes required.

Step 6 of the legacy cleanup plan, group G (network path).

cc
2026-04-26 18:21:26 -06:00
vicrodh 145b589c2a refactor(v2): inline folder settings and track-by-path delegates
Inlines the bodies of `library_update_folder_settings` and
`get_track_by_path` from crate::library::commands::* directly into
commands_v2/legacy_compat.rs. Maps the legacy String error type to
RuntimeError::Internal at every return path.

Deletes the legacy #[tauri::command] definitions in
library/commands.rs. The data-layer methods on LibraryDatabase
(update_folder_settings, get_folder_by_id, get_track_by_path) are
unchanged.

No frontend invoke changes required.

Step 6 of the legacy cleanup plan, group F (folder settings + track
lookup).

cc
2026-04-26 18:19:59 -06:00
vicrodh 99729d3888 refactor(v2): inline library_backfill_downloads delegate
Inlines the body of `library_backfill_downloads` from
crate::library::commands::* directly into commands_v2/legacy_compat.rs,
mapping the legacy String error type to RuntimeError::Internal at every
return path. Adjusts the v2 return type to refer to
crate::library::BackfillReport (the canonical re-export from
qbz-library) instead of the deleted crate::library::commands path.

Deletes the legacy #[tauri::command] definition in
library/commands.rs. MetadataExtractor and BackfillReport are reused
unchanged from their crate::library re-exports.

No frontend invoke changes required.

Step 6 of the legacy cleanup plan, group E (backfill downloads).

cc
2026-04-26 18:18:15 -06:00
vicrodh afa8298745 refactor(v2): inline offline cache fully-cached delegates
Inlines the bodies of `check_album_fully_cached` and
`check_albums_fully_cached_batch` from
crate::offline_cache::commands::* directly into
commands_v2/legacy_compat.rs, mapping the legacy String error type to
RuntimeError::Internal at the v2 boundary.

Note: v2_cache_notification_artwork is NOT a delegate — it lives in
commands_v2/helpers.rs and is a true helper used by other v2 commands.
The investigation list flagged it as a possible delegate, but
verification showed it owns its implementation.

Deletes the legacy #[tauri::command] definitions in
offline_cache/commands.rs. Other functions in the offline_cache::commands
module remain (still called from commands_v2/library.rs delegates,
which are step 8 scope).

No frontend invoke changes required.

Step 6 of the legacy cleanup plan, group D (offline cache).

cc
2026-04-26 18:16:02 -06:00
vicrodh 515f73b259 refactor(v2): inline offline scrobble/playlist delegates
Inlines the bodies of `create_pending_playlist`,
`get_pending_playlist_count`, `queue_scrobble`, `get_queued_scrobbles`,
`get_queued_scrobble_count`, and `cleanup_sent_scrobbles` from
crate::offline::commands::* directly into commands_v2/legacy_compat.rs.

The legacy bodies were uniform: lock state.store, unwrap optional store,
delegate to a method on the store struct. Inlined verbatim with the
String error type mapped to RuntimeError::Internal at each return path.
The store-side OfflineStore methods (data layer) are untouched.

Deletes the legacy #[tauri::command] definitions in offline/mod.rs.
Other functions in the offline::commands module remain (still called
from commands_v2/library.rs delegates, which are step 8 scope).

No frontend invoke changes required.

Step 6 of the legacy cleanup plan, group C (offline scrobble/playlist).

cc
2026-04-26 18:14:14 -06:00
vicrodh 40b8ec90a0 refactor(v2): inline discogs delegates and delete legacy discogs commands
Inlines the bodies of `discogs_has_credentials`,
`discogs_search_artwork`, and `discogs_download_artwork` from
crate::library::commands::* directly into commands_v2/legacy_compat.rs.
The discogs delegates were single-line bodies (or trivial proxy logic);
DiscogsClient and library::get_artwork_cache_dir are reused as-is.

The String error type from the underlying DiscogsClient methods is
mapped to RuntimeError::Internal at the v2 boundary, matching the
existing v2 contracts.

Deletes the legacy #[tauri::command] definitions from
library/commands.rs. No frontend invoke changes required.

Step 6 of the legacy cleanup plan, group B (discogs).

cc
2026-04-26 18:11:58 -06:00
vicrodh ba878b8419 refactor(v2): inline lyrics delegates and delete legacy lyrics commands
Inlines the bodies of `lyrics_get` and `lyrics_get_cache_stats` from
crate::lyrics::commands::* directly into commands_v2/legacy_compat.rs,
mapping the legacy String error type to RuntimeError::Internal at the
return boundary for v2_lyrics_get. Renames LyricsCacheStats to
V2LyricsCacheStats, hosted next to the inlined v2_lyrics_get_cache_stats.

Deletes lyrics/commands.rs entirely (also drops the dead
lyrics_clear_cache helper which had no callers; v2_lyrics_clear_cache in
commands_v2/library.rs already inlines its own logic).

The v2 functions retain their existing names, parameter shapes, and
Tauri::State signatures; only the body changes. No frontend invoke
changes required.

Step 6 of the legacy cleanup plan, group A (lyrics).

cc
2026-04-26 18:09:59 -06:00
vicrodh 42f872fd86 Merge chore/internal/legacy-dead-code-sweep into pre-release 2026-04-26 17:53:25 -06:00
vicrodh ac44b8a713 refactor(cast): move CastState and DlnaState to dedicated state modules
Relocates CastState (struct + impl) from cast/commands.rs to
cast/state.rs, and DlnaState (struct + impl) from
cast/dlna/commands.rs to cast/dlna/state.rs.

Both types are consumed by commands_v2/library.rs (and the legacy
Tauri commands themselves) via the existing re-exports in
cast/mod.rs and cast/dlna/mod.rs. The re-exports are updated to
source from the new state modules, and each commands.rs re-exports
its state type so historical
crate::cast::commands::CastState / crate::cast::dlna::commands::DlnaState
paths continue to compile.

This unblocks future deletion of cast/commands.rs and
cast/dlna/commands.rs in a later cleanup step (the
#[tauri::command] functions in those files are already
unregistered and have zero Rust call sites).

cc
2026-04-26 17:19:21 -06:00
vicrodh e0dc464a65 refactor(library): move state types out of commands.rs
Relocates LibraryState (struct + impl), CleanupResult, BackfillReport,
LibraryAlbumMetadataUpdateRequest, and LibraryAlbumTrackMetadataUpdate
from library/commands.rs to a new library/state.rs module.

These types are consumed by commands_v2/library.rs,
commands_v2/legacy_compat.rs, commands_v2/mixtapes.rs,
commands_v2/link_resolver.rs, cast/commands.rs, and
session_lifecycle.rs. The existing pub use re-exports in
library/mod.rs are updated to source from state, and commands.rs
re-exports them in turn so historical
crate::library::commands::* paths (still used by
commands_v2/legacy_compat.rs, which is out of scope for this task)
continue to compile.

This unblocks future deletion of library/commands.rs once the
remaining #[tauri::command] definitions there are inlined into
their commands_v2 callers (separate task).

No serde shape changes; struct fields, derives and serde
attributes are preserved verbatim, so the JSON the frontend
receives is identical.

cc
2026-04-26 17:12:47 -06:00
vicrodh aecdd4f5c8 chore(legacy): delete dead Tauri commands in commands/playback.rs
Removes 11 #[tauri::command] functions that are unregistered and have
zero Rust call sites:
- pause_playback, resume_playback, stop_playback
- play_next_gapless, set_media_metadata
- get_audio_devices, reinit_audio_device
- get_audio_output_status, get_pipewire_sinks
- set_pipewire_default_sink, debug_get_cpal_devices

play_track, seek, and set_volume are preserved - they are still
consumed by src-tauri/src/api_server/mod.rs (REST endpoints for
the PWA remote). They will be migrated to call commands_v2/playback
or core_bridge in a later step.

Helper functions used solely by the deleted commands are also
removed: AudioDevice, AudioOutputStatus, PipewireSink structs,
get_pipewire_device_descriptions (linux + non-linux), and
cpal_device_name. Helpers reachable from play_track / seek /
set_volume / prefetch_track / get_playback_state (parse_quality,
limit_quality_for_device, download_audio, get_stream_info,
extract_audio_format_from_header, download_and_stream,
spawn_prefetch, prefetch constants and PREFETCH_SEMAPHORE) are
preserved.

cc
2026-04-26 17:07:56 -06:00
vicrodh 92ceb025a1 chore(legacy): delete src-tauri/src/commands/search.rs
All 20 #[tauri::command] functions in this file are unregistered
(no longer in invoke_handler) and have zero Rust call sites. V2
equivalents in commands_v2/search.rs and commands_v2/catalog.rs
cover all use cases.

Removed:
- search_albums, search_tracks, search_artists, search_all
- get_album, get_featured_albums, get_track, get_artist
- get_similar_artists, get_genres
- get_discover_index, get_discover_playlists, get_playlist_tags, get_discover_albums
- get_artist_page, get_artist_basic, get_artist_detail
- get_artist_tracks, get_artist_albums, get_releases_grid

The corresponding pub mod search; declaration in commands/mod.rs is
also removed.

The SearchAllResults / MostPopularItem types (used by api_server's
REST /search/all endpoint, the PWA remote control surface) are
relocated into api_server/mod.rs since that is the only remaining
consumer; this avoids leaving a near-empty file just to host two
serde DTOs.

cc
2026-04-26 17:04:10 -06:00
vicrodh 49df57675a chore(legacy): drop 15 unused legacy command registrations from invoke_handler
These commands had v2_* equivalents registered alongside them; the
frontend has been migrated and no longer invokes them. The legacy
function definitions remain in their respective modules - they will
be removed in subsequent steps once we verify nothing internally
calls them.

cc
2026-04-26 17:00:51 -06:00
vicrodh fd545eccaa Merge refactor/internal/v2-cleanroom-final into pre-release 2026-04-26 13:07:17 -06:00
vicrodh c70db4157a refactor(v2): migrate get_pipewire_sinks to v2
Path C: legacy CPAL output-sink enumeration was inlined directly in
commands/playback.rs (cfg-gated Linux + non-Linux variants), with
no equivalent in any domain crate. Extract a domain function
`qbz_audio::output_sinks::list_output_sinks` that returns the same
{name, description, volume, is_default} shape (mirrored as the new
public `OutputSinkInfo` struct) for both Linux and non-Linux
platforms, preserving the original logging cadence and the rule
that name == CPAL device name (so saved IDs reopen correctly).

Add v2_get_pipewire_sinks in commands_v2/audio.rs that calls into
qbz_audio directly — no `crate::commands::*` delegation. Updates the
two frontend call sites (AudioOutputBadges.loadStatus and
SettingsView.loadAudioDevices). Legacy command in
commands/playback.rs is preserved untouched; the audio-protected
files (pipewire_backend.rs, audio_settings.rs, player init_device)
are not modified.

cc
2026-04-26 12:03:28 -06:00
vicrodh 1abb57bd7f refactor(v2): migrate get_audio_output_status to v2
Path B: legacy command read state.player.state.current_device() and
state.player.state.is_playing() — both methods already exist on the
qbz-player Player.state in the V2 architecture. No domain extraction
needed; the v2 command reads them via CoreBridge.player() like the
existing v2_get_hardware_audio_status does.

Adds v2_get_audio_output_status returning V2AudioOutputStatus
(serialized to the same {device_name, is_playing} shape so the
AudioOutputBadges frontend type is unchanged). Updates both
AudioOutputBadges call sites (initial loadStatus + the 1s polling
interval). The legacy command in commands/playback.rs is preserved
untouched.

cc
2026-04-26 12:00:15 -06:00
vicrodh eb17cc6d22 refactor(v2): migrate get_releases_grid to v2
Path C: legacy command called client.get_releases_grid for the
artist detail view's release-type pagination grid. No existing
v2 equivalent, so add v2_get_releases_grid via QbzCore ->
QobuzClient::get_releases_grid directly.

cc
2026-04-26 11:51:33 -06:00
vicrodh 7d89ae77ef refactor(v2): migrate get_artist_tracks to v2
Path C: legacy command called client.get_artist_tracks (the
/artist/get?extra=tracks path) and returned a TracksContainer.
No existing v2 equivalent, so add v2_get_artist_tracks via
QbzCore -> QobuzClient::get_artist_tracks directly.

cc
2026-04-26 11:48:29 -06:00
vicrodh 0bc033b324 refactor(v2): migrate get_artist_detail to v2
Path C: legacy command called client.get_artist_detail with the
hard-coded mobile defaults of limit=1000, offset=0 to populate the
suggestions panel's playlists and tracks_appears_on. No close v2
equivalent existed (v2_get_artist_with_albums uses
extra=albums only), so add v2_get_artist_detail that goes through
QbzCore -> QobuzClient::get_artist_detail directly.

cc
2026-04-26 11:45:47 -06:00
vicrodh 1dcb7765d3 refactor(v2): migrate get_artist_albums to v2
Path C: legacy command projected `Artist.albums` from
client.get_artist_with_pagination(with_albums=true). The existing
v2_get_artist_with_albums returns the full Artist envelope, so
add a dedicated v2_get_artist_albums in QbzCore that performs the
same projection and exposes only ArtistAlbums to match what the
two callsites destructure.

cc
2026-04-26 11:43:23 -06:00
vicrodh a127de0f32 refactor(v2): migrate get_artist_basic to v2
Path A: existing v2_get_artist already returns Artist via
QbzCore::get_artist which delegates to client.get_artist_basic;
shape matches the legacy command exactly so the frontend just
swaps the invoke name.

cc
2026-04-26 11:37:00 -06:00
vicrodh 8ef6f9f02c refactor(v2): migrate get_flatpak_help_text to v2
Add v2_get_flatpak_help_text as a thin wrapper over the existing
flatpak::get_flatpak_guidance(). Update the frontend invoke in
SettingsView.svelte to use the v2 name. Legacy command remains for
now.

Audit: legacy_callsites 12 -> 11.

cc
2026-04-26 11:23:40 -06:00
vicrodh a393d98218 refactor(v2): migrate detect_desktop_theme to v2
Add v2_detect_desktop_theme as a thin wrapper over the existing
desktop_theme::detect_desktop_theme(). Update the frontend invoke in
windowControlsStore.ts to use the v2 name. Legacy command remains
for now.

Audit: legacy_callsites 13 -> 12.

cc
2026-04-26 11:21:34 -06:00
vicrodh dddffe8b86 Merge bugfix/internal/qconnect-test-renderer-id into pre-release 2026-04-26 11:20:57 -06:00
vicrodh 57c5c56bcb refactor(v2): migrate get_qobuz_tos_accepted to v2
Add v2_get_qobuz_tos_accepted as a thin wrapper over the existing
config::legal_settings::get_qobuz_tos_accepted. Update the frontend
invoke in qobuzLegalStore.ts to use the v2 name. Legacy command
remains for now.

Audit: legacy_callsites 14 -> 13.

cc
2026-04-26 11:20:11 -06:00
vicrodh 120e3fb827 refactor(v2): migrate has_snap_welcome_been_shown to v2
Add v2_has_snap_welcome_been_shown as a thin wrapper over the
existing UpdatesStore::has_snap_welcome_been_shown(). Update the
frontend invoke in updatesService.ts to use the v2 name. Legacy
command remains for now.

Audit: legacy_callsites 15 -> 14.

cc
2026-04-26 11:18:54 -06:00
vicrodh 0fdb539044 refactor(v2): migrate has_flatpak_welcome_been_shown to v2
Add v2_has_flatpak_welcome_been_shown as a thin wrapper over the
existing UpdatesStore::has_flatpak_welcome_been_shown(). Update the
frontend invoke in updatesService.ts to use the v2 name. Legacy
command remains for now.

Audit: legacy_callsites 16 -> 15.

cc
2026-04-26 11:17:32 -06:00
vicrodh 33cd092956 fix(qconnect-tests): use runtime device info for fingerprint fallback test
The test was added in 145974c29 when the default device name was a
const "QBZ Desktop". A later refactor (bf91822b4) replaced the const
with hostname-derived "Qbz - {hostname}" plus env-var/persisted-DB
overrides. This left the renderer fixture asserting a friendly_name
that no longer matches what default_qconnect_device_info() returns,
so refresh_local_renderer_id correctly returned None and the test
panicked.

Read the local device info inside the test and use those fields for
the renderer that should match. The test now exercises the contract
("when device_uuid is missing, fall back to a unique fingerprint
match against the local device info") regardless of host environment.

cc
2026-04-26 10:50:03 -06:00
vicrodh b05edc08b7 Merge bugfix/internal/playlist-custom-order into pre-release 2026-04-26 10:24:26 -06:00
vicrodh 6ba53f40b1 Merge bugfix/internal/qconnect-cleanup into pre-release 2026-04-26 10:24:26 -06:00
vicrodh d46b0c870a fix(qconnect): skip cursor align when local is active renderer (#316)
When QConnect is on AND the local app is the active renderer, every
local next/play_index round-trips back through the Qobuz cloud as a
SET_STATE echo. apply_renderer_command_to_corebridge unconditionally
called align_corebridge_queue_cursor, which races the user's already-
advanced local cursor and triggers a second play_index, pushing a
duplicate index onto QueueManager.history. That is the duplicates half
of issue #316.

Mirror the gating that sync_active_renderer_projection already uses:
align the CoreBridge cursor only when local is NOT the active renderer.
The existing peer-driven flow (peer is active renderer, echoes arrive
to drive our cursor + history) is unchanged.

cc
2026-04-25 21:37:38 -06:00
vicrodh 9db79ecd09 fix(queue): preserve history across set_queue when tracks survive (#316)
set_queue and set_queue_with_order unconditionally cleared history, which
wiped legitimate plays whenever QConnect echoed a queue version bump
(reorder, shuffle toggle, peer-driven snapshot) without changing track
identity. That is the missing-tracks half of issue #316.

Instead, remap history entries by track id: each pre-existing index is
resolved to its track id via the old tracks list, then looked up in the
new tracks list. Entries whose track id is no longer present are dropped.
When the new queue has zero overlap with the old, history clears
naturally; when the queue is a pure reorder, history survives intact.

Six unit tests cover pure reorder, additions, partial removals,
disjoint replacement, and verify the remapped indices (not just rendered
ids) point at the right new tracks.

cc
2026-04-25 21:35:49 -06:00
vicrodh 97ab63fd9b refactor(qconnect): simplify lifecycle_state JSON serialization
Drop the to_string->from_str round-trip in favor of a direct to_value
call. Same fallback behavior on serializer error.

cc
2026-04-25 21:16:37 -06:00
vicrodh 694f1a1044 refactor(qconnect): rename NowPlayingBar prop to isQobuzConnectToggleOn
Previously the prop was named isQobuzConnectConnected, which suggested
transport-up semantics, but after 23658b05 it carries the user-intent
toggle state (true during Connecting and Reconnecting too). Rename to
match what the value actually means, removing the chance of a future
reader misreading the prop as a "safe to send commands" gate.

No behavior change.

Refs: #358

cc
2026-04-25 21:15:26 -06:00
vicrodh 47afe9037b fix(qconnect): reset lifecycle to Off when transport connect fails
Previously, an Err from app.connect() would propagate via ? while
lifecycle_state remained at Connecting and runtime was None, leaving
the frontend toggle stuck "on" (because isQconnectToggleOn treats
Connecting as on) with no live runtime. This recreated the exact
user-visible bug 23658b05 was trying to fix, just triggered from a
different upstream failure.

Convert the ? propagation into explicit error handling that resets
lifecycle_state = Off and records last_error before returning Err,
so the toggle correctly flips off and a subsequent connect() starts
fresh.

Refs: #358

cc
2026-04-25 21:14:04 -06:00
vicrodh 23658b053c fix(qconnect): decouple toggle state from transport so user can disable a stuck reconnect
When the reconnect loop is stuck waiting for a SESSION_STATE that never
comes (the actual symptom of issue #358), the runtime is alive but
`transport_connected` is false. The UI used to read its on/off toggle
from `transport_connected`, so the badge appeared OFF while the backend
loop kept retrying. Worse: clicking the toggle in that state called
`connect()`, which rejected with "QConnect service is already running".
The user had no way to disable QConnect from the UI — only to "turn it
on more", which failed.

Backend (`qconnect_service.rs`):

- Add `QconnectLifecycleState` enum (Off / Connecting / Connected /
  Reconnecting / Exhausted) and surface it on
  `QconnectConnectionStatus::state`. Driven from the spawned transport
  event loop via a new `update_lifecycle_state_if_running` helper which
  refuses to overwrite terminal states (Off / Exhausted) with late
  Reconnecting / Connected events from the broadcast queue.
- Make `connect()` idempotent: if a runtime already exists (alive but
  reconnecting), return current status instead of erroring. The toggle
  is the single user-visible control; the previous "already running"
  error was unactionable from the UI.
- Auto-stop on `MaxReconnectAttemptsExceeded`: take the runtime out,
  set state=Exhausted, set last_error to the cloud reason, and emit a
  `qconnect:status_changed` event so the frontend reflects the new
  state without waiting for its 5 s poll. The transport loop has
  already exited at this point, so a fresh user-initiated connect()
  cleanly starts a new runtime.
- `disconnect()` now forces lifecycle to Off unconditionally and tears
  down the runtime (already worked, the transport's `shutdown_tx`
  watch breaks `handle_reconnect_delay` mid-sleep), satisfying the
  "disable QConnect must always succeed" requirement.

Frontend (`qconnectRuntime.ts`, `+page.svelte`):

- Add `QconnectLifecycleState` mirror type and `isQconnectToggleOn()`
  helper.
- Split state in the page: `isQobuzConnectConnected` keeps its old
  semantics (`transport_connected`) and gates send-command paths
  (volume reports, position reports) so we don't fire commands while
  the WS is down. New `isQobuzConnectToggleOn` reflects the
  user-facing on/off — true even during Reconnecting/Connecting — and
  is what the badge component and the toggle handler now read.
- Listen for the new `qconnect:status_changed` event so the toggle
  reflects auto-stop (Exhausted) and other transitions immediately
  instead of after the 5 s status poll.

Refs #358

cc
2026-04-25 21:02:30 -06:00
vicrodh d74332514d fix(qconnect-transport-ws): decode MSG_TYPE_ERROR frame and surface code+descr
The MSG_TYPE_ERROR (cloud_type=9) arm previously logged only the payload
byte count, so when Qobuz cloud rejected a session (zombie session,
conflicting device, expired JWT, ...) we had no signal beyond an opaque
"bytes=13" TransportError. That hid the actual cause of the 2s flicker
loop seen after the app sat minimized for 1-2 hours (issue #358).

The qws.proto ErrorMessage (messageTypeId=9) carries:
  uint32 msg_id   = 1
  uint64 msg_date = 2
  uint32 code     = 3
  string descr    = 4

Decode it via a local QwsErrorMessage prost struct, log a warn line with
the decoded code+descr, and emit a new TransportEvent::CloudError so
qconnect-app and qconnect_service can react. qconnect_service forwards
it as a "qconnect:cloud_error" diagnostic (level=warning) and logs it.
qconnect-app treats it as a no-op forwarder for now, matching the
existing pattern for other transport-only events. Decode failures fall
back to the previous TransportError path under a distinct stage name so
they are still visible.

Adds a tokio test that builds a real MSG_TYPE_ERROR frame and asserts
the transport emits CloudError (not the legacy TransportError).

Refs #358

cc
2026-04-25 20:44:46 -06:00
vicrodh ecc27bbcaa fix(qconnect-transport-ws): reset backoff only on SESSION_STATE, cap reconnect attempts
The native WS transport reset its reconnect counters as soon as the WS
upgrade succeeded. Qobuz cloud accepts the WS connection well before the
JOIN_SESSION exchange and rejects unhealthy sessions with MSG_TYPE_ERROR
right after, so the counter reset every cycle and the loop hammered the
endpoint at base backoff (~2s) forever (issue #358).

Move the counter reset to the moment the cloud emits a
MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE event (the actual proof of a healthy
session-level join), and add a configurable maximum-attempts cap that
makes the transport stop and emit MaxReconnectAttemptsExceeded instead
of looping indefinitely.

- Add SessionEstablished and MaxReconnectAttemptsExceeded transport events.
- Add reconnect_max_attempts to WsTransportConfig (default Some(10)).
- handle_incoming_binary now returns whether SESSION_STATE was decoded.
- Reconnect helper returns a ReconnectOutcome enum (Continue/Shutdown/
  Exhausted) instead of a bare bool.
- qconnect_service.rs logs the new events and emits a diagnostic when
  the cap is exceeded so the higher layer can react (#358-fix-3 will
  surface it in the UI).
- Unit tests cover the cap behavior and the unbounded fallback.

Refs #358

cc
2026-04-25 20:01:42 -06:00
vicrodh e3caff7f65 chore(audit): add config::favorites_cache to legacy_module_roots
Completes the legacy root list by adding config::favorites_cache,
which is registered in lib.rs (e.g., is_track_favorite) but was
missing from the audit's known-roots list. No frontend currently
invokes it, so this is preventive — guards against future silent
misclassification as 'unknown'.

Found in code review of commit 3ea96fb5.

cc
2026-04-25 19:45:01 -06:00
vicrodh 3ea96fb5ee chore(audit): classify legacy commands by leaf identifier and add roots
The frontend invoke audit was capturing the wrong segment of legacy
handler registrations. The pattern 'commands::([A-Za-z0-9_]+)' grabbed
the submodule name (e.g. 'search', 'playback') instead of the function
leaf, so commands like 'commands::search::get_artist_albums' fell into
the unknown bucket even though they were registered.

Switch to a per-root regex of the form
'<root_path>(::<segment>)*::(<leaf>),' which captures the leaf
identifier regardless of how many submodule levels sit between the root
and the function. Also add four legacy module roots that were missing:
- updates (has_flatpak_welcome_been_shown, has_snap_welcome_been_shown)
- config::legal_settings (get_qobuz_tos_accepted)
- desktop_theme (detect_desktop_theme)
- flatpak (get_flatpak_help_text)

After this change the unknown bucket is empty and the 16 callsites that
were previously misclassified as 'unknown' now correctly show up as
'legacy', which is the accurate status pending future V2 migration.

cc
2026-04-25 18:23:53 -06:00
vicrodh 4f0e4e6c81 fix(commandRouter): rewire audio invokes to actual v2 command names
cmdUpdateAudioSettings was invoking four v2 commands that do not exist:
- v2_set_audio_backend       -> v2_set_audio_backend_type
- v2_set_audio_device        -> v2_set_audio_output_device
- v2_set_exclusive_mode      -> v2_set_audio_exclusive_mode
- v2_set_dac_passthrough     -> v2_set_audio_dac_passthrough

Also corrects the parameter for set_audio_output_device, which expects
'device' (not 'deviceName'). The other three already use the correct
parameter names. The function currently has no callers, but keeping the
shape correct prevents a future caller from hitting silent failures.

cc
2026-04-25 18:23:01 -06:00
vicrodh 404296f49f fix(diagnostics): rename v2_qconnect_get_status to v2_qconnect_status
The frontend was invoking 'v2_qconnect_get_status', but the backend
command is registered as 'v2_qconnect_status' under qconnect_service.
This caused two callsites in the diagnostics path to fail at runtime:
- src/lib/services/diagnosticsSnapshot.ts
- src/lib/components/DiagnosticsPanel.svelte

cc
2026-04-25 18:22:22 -06:00
vicrodh 58443b020e fix(scripts): audit-frontend-invokes recognizes commands from any namespace
The previous regex only matched commands registered under commands_v2::,
missing 50+ legitimate v2_qconnect_* commands registered under
qconnect_service::. This produced 64 false-positive "missing_v2" callsites
that obscured real audit signal.

Generalize the registration regex to match any module prefix ending in
::v2_* or ::runtime_*, so future modules that follow the v2_ naming
convention are picked up automatically without script changes. The inner
alternation still excludes legacy commands, which never carry the v2_ or
runtime_ prefix on the function name.

After the fix, missing_v2_callsites drops from 64 to 6 (the remaining 6
are real frontend typos, not audit-script bugs); legacy_callsites stays
at 0; unknown_callsites stays at 16.

cc
2026-04-25 18:07:06 -06:00
vicrodh 9cfaf755f5 Merge feature/internal/track-shuffle-mix into pre-release 2026-04-25 17:46:36 -06:00
vicrodh 13bd95a663 fix(playlist): rebuild custom order from visible state on every move
Custom-sort reordering inside a playlist (arrow buttons + drag and
drop) silently no-op'd or shifted by the wrong amount. Investigation
on a 15-track playlist showed customOrderMap with duplicate positions
(two tracks at position 6) and the moved track at map-position 10
while displayed at index 11 — the in-place shift algorithm assumed
display-index and map-position lived in the same number space, which
is only true when positions are unique 0..N-1.

Once any duplicate sneaked in, every subsequent click hit the no-op
path: target newPosition equalled current map-position, the per-key
shift loop's `pos > fromIndex && pos <= toIndex` window collapsed to
empty, and the backend's move_playlist_track saw current==new and
returned without writing. The user had to toggle sort mode to force
loadOrInitCustomOrder to re-fetch backend state — which masked the
fact that the local algorithm itself was wedged.

This rewrites moveTrack to ignore the existing map values and instead
splice the visible displayTracks order, then renumber 0..N-1. The new
order is shipped to v2_playlist_set_custom_order, whose backend impl
is a DELETE + INSERT for the playlist, so any pre-existing corruption
self-heals on the first move.

Concurrent clicks are serialized through a Promise chain so backend
writes land in click order even though tokio's mutex isn't strictly
FIFO. The optimistic local update happens synchronously before the
chain dispatch, so subsequent clicks see the post-move displayTracks
state without waiting for the round-trip.

Note: the latent bug in database.rs::move_playlist_track that lets
duplicate positions exist in the first place (the INSERT branch on
line 3354 doesn't shift existing rows) is not addressed here — the
new flow simply doesn't use that code path. Backend cleanup is a
separate fix.

cc
2026-04-25 13:06:21 -06:00
vicrodh 603c78cadd fix(ui): rebuild TrackMixModal on shared Modal + Dropdown + .btn classes
Replaces the first-pass implementation that rolled its own backdrop and
pill-style size buttons. The pills had two problems: pills are forbidden
by the project design rules (qbz-nix-docs/design/FIGMA_PROMPT.md), and
the custom modal centered itself with `transform: translate(-50%, -50%)`
which created a containing block for `position: fixed` descendants —
that broke `Dropdown.svelte`'s portaled menu, which landed in the
bottom-right corner of the screen instead of below the trigger.

Now wraps `Modal.svelte` (portaled to body, flexbox-centered, ESC
built in), uses `Dropdown.svelte` for the size picker, and follows
the project convention of `<div class="modal-actions">` + the global
`.btn .btn-primary` / `.btn .btn-secondary` classes for the footer.

Copy is reworked to read like a sentence: title "Random queue", body
"Create a random queue with songs from this collection. How many songs
do you want?", confirm button "Add to queue". Synced across en/es/de/fr/pt.
Drops the now-unused `mixModal.subtitle` key.

Also removes the duplicated mixModalOpen entry from the view's
keydown ESC chain — `Modal.svelte` already handles ESC.

cc
2026-04-25 13:05:42 -06:00
vicrodh 656b40cd13 chore(deps): refresh Cargo.lock for strsim direct dep
Side effect of promoting strsim from transitive to direct in commit
f048ee76 (feat(mixtape): add shuffle module with normalize +
token_set_ratio).

cc
2026-04-25 06:30:21 -06:00
vicrodh 83c276dc43 feat(ui): wire DJ Mix button into collection/mixtape detail view
Adds a third circular header action between Shuffle and More that
opens TrackMixModal, then delegates the resolved sampleSize to
v2_collection_shuffle_tracks. Replaces the queue, starts playback,
and surfaces a toast when actualCount < requestedCount (dedup or
per-album cap shrunk the pool).

ESC chain extended to dismiss the mix modal before closing other UI.
collection.play_mode is left untouched.

cc
2026-04-25 06:26:22 -06:00
vicrodh 48f4d130e4 feat(ui): add TrackMixModal with size picker + i18n keys
- TrackMixModal.svelte renders a centered radiogroup of queue sizes
  (50, 100, 150, …, "All (N)") backed by buildSizeOptions. Fetches the
  unique track count via v2_collection_unique_track_count when first
  opened for a collection and caches it for subsequent opens of the
  same collection.
- buildSizeOptions is a pure helper extracted to a separate file so it
  can be unit-tested cleanly. 8 vitest cases cover the empty/null,
  small, exact-50, multiple-of-50, and irregular paths.
- Adds 8 new i18n keys (common.shuffleTracksMix, mixModal.title /
  computing / subtitle / allOption / confirm / loadFailed,
  toast.mixTrimmed) across all 5 locale files.
- z-index 3000/3001 per ADR-003.

cc
2026-04-25 06:24:09 -06:00
vicrodh 404d6f431b feat(v2): add v2_collection_shuffle_tracks command
End-to-end DJ-mix command. Loads the collection, resolves its natural
in-order track list, dedupes near-duplicate versions, samples up to
sample_size tracks under the per-album cap, replaces the queue, and
starts playback from index 0. Stamps queue_source_collection and bumps
the play counter (mirroring v2_enqueue_collection's replace mode), but
does not modify the collection's persistent play_mode.

Returns ShuffleTracksResult { requestedCount, actualCount } so the
frontend can show the trimmed-mix toast when the cap or dedup shrinks
the pool.

ThreadRng is not Send, so dedup + sample run inside a sync scope that
ends before the bridge awaits.

cc
2026-04-25 06:18:24 -06:00
vicrodh da33e43257 feat(v2): add v2_collection_unique_track_count command
Resolves the collection's natural in-order track list and returns the
count of distinct songs after similarity-based deduplication. Used by
the upcoming track-mix modal to size the slider of pickable queue
sizes. Auth gated; deterministic output.

cc
2026-04-25 06:12:02 -06:00
vicrodh 26b39495d8 feat(mixtape): add hybrid_sample with per-album cap
Round-robin sampling that picks up to `requested` tracks from a
deduped track list. Each album bucket is shuffled in place and
truncated to cap = max(2, ceil(requested * 0.30)); rounds permute
the album order so cross-album distribution is randomized too. May
return fewer than `requested` when the per-album cap and bucket
sizes do not add up — callers report this gap to the user.

7 new tests (37 total) including a 200-seed statistical test
confirming the cap is never exceeded.

cc
2026-04-25 06:05:18 -06:00
vicrodh 6649ee0759 feat(mixtape): add dedup_by_similarity and unique_track_count
- unique_track_count returns the count of distinct songs after grouping
  near-duplicate versions; deterministic, no RNG.
- dedup_by_similarity collapses each duplicate group to one survivor
  picked at random via the supplied RNG. Output preserves input order
  of survivors.

Both share a single bucketed Union-Find pass: tracks are first grouped
by exact normalized artist, then unioned within each artist bucket
when their normalized titles score >= 0.80 on token_set_ratio. Cross-
artist comparisons are skipped to avoid false positives like "Yesterday"
by different artists.

12 new tests (30 total in this module) including a 200-seed statistical
test confirming every duplicate version can be selected as the winner.

cc
2026-04-25 05:34:11 -06:00
vicrodh f048ee7645 feat(mixtape): add shuffle module with normalize + token_set_ratio
Pure-function helpers used by the upcoming track-mix sampler:
- normalize_title strips parens/brackets, dash suffixes, feat patterns,
  punctuation, and diacritics.
- normalize_artist keeps parens (band suffixes carry meaning) and just
  lowercases + strips diacritics.
- token_set_ratio implements the RapidFuzz-style token-set similarity in
  [0.0, 1.0] using strsim's normalized Levenshtein.

Promotes strsim from transitive to direct dep. 18 unit tests cover the
core normalization and similarity behaviors.

cc
2026-04-25 05:22:02 -06:00