Commit Graph
1299 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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
vicrodh 89c0d65285 feat(memory): scale L1 audio cache cap to host memory profile (#331)
Add audio_cache_l1_max_bytes to MemoryProfile and wire it into
AppState's AudioCache constructor. Normal hosts keep the historical
400 MB cap; LowMemory hosts drop to 50 MB.

Without this, a Pi 3B (1 GB total) starts with the L1 cache cap set
at ~40 % of physical RAM. Even with the Feature #3 prefetch caps
already in place, the runtime watchdog from the previous commit
ends up firing more often than necessary because nothing prevents
a single L1 fill cycle from claiming 400 MB. Capping at 50 MB caps
how much the cache can grow before either: (a) eviction picks up
naturally, or (b) the watchdog steps in if the rest of the system
is also under pressure.

Pure refactor in lib.rs — replaces a hard-coded 400 * 1024 * 1024
with a profile field read. The default-constructed AudioCache
fallback (when disk-spillover init fails) keeps its own crate
default; no need to thread the profile there since the failure
path is rare.

Two tests added: a presence-bound assertion in the existing Pi 3B
case, and an explicit comparison that LowMemory's cap is strictly
smaller than Normal's.

cc
2026-04-24 19:45:38 -06:00
vicrodh c6fc01961f feat(memory): spawn audio-cache eviction watchdog on low-memory hosts (#331)
Tauri setup() now spawns a tokio task that polls
qbz_core::system_capabilities::read_memory_pressure() every 10 s
when the resolved MemoryProfile classifies the host as LowMemory.
On Normal-class machines the task is never spawned, so polling cost
is zero where it isn't needed.

Behavior:
- pressure.is_critical (< 5 % MemAvailable) triggers
  audio_cache.clear() once per critical episode and logs at warn.
  Both L1 (in-memory tracks) and L2 (disk spillover) are flushed —
  the disk hit on subsequent playback is far cheaper than letting
  the OS swap-thrash through the audio thread.
- pressure.is_low (< 15 %) is logged once at info as an early
  warning but does not evict.
- Recovery (back above 15 %) logs once and arms the next critical
  episode.

The was_critical edge guard prevents log spam when pressure
oscillates near the threshold during heavy prefetch.

For codehd7's Pi 3B scenario (issue #331): when QBZ pushes
MemAvailable below ~47 MB (5 % of 938 MB), the watchdog flushes
the cache, frees memory back to the kernel, and the network stack
recovers from the swap thrash that was killing playback.

cc
2026-04-24 19:43:45 -06:00
vicrodh b38065a033 feat(streaming): cap StreamingConfig initial buffer per memory profile (#331)
StreamingConfig::from_speed_mbps inflates the initial buffer to 2 MB
on slow connections (< 1 MB/s) to absorb network jitter. On a healthy
desktop that's fine, but on a memory-constrained host the slow link
is itself a symptom: when QBZ on a Pi 3B starts allocating ~300 MB
of Hi-Res prefetch the kernel swaps, networking stalls, the speed
detector reports < 1 MB/s, and the buffer code reacts by allocating
ANOTHER 2 MB — exactly the wrong direction.

Add a process-wide AtomicUsize cap (defaults to usize::MAX, i.e.
no-op) plus a small public set_max_initial_buffer_bytes setter.
Refactor from_speed_mbps to clamp through the cap; extract the
speed-to-bytes ladder into a pure raw_initial_buffer_for_speed
helper plus a from_speed_mbps_with_cap variant that exposes the
clamping for unit tests without touching global state.

src-tauri/lib.rs's run() wires the cap into qbz-player from
qbz-core's MemoryProfile right after logging the profile. Effective
on Pi 3B (issue #331): all StreamingConfig::from_speed_mbps results
pin to 256 KB regardless of detected speed.

Re-export max_initial_buffer_bytes / set_max_initial_buffer_bytes
from qbz_player::player so external callers don't need to reach
into the private streaming_source module.

Adds 5 unit tests: ladder behavior, pass-through when cap is loose,
clamp at the LowMemory cap (the issue #331 case), no-op at the
Normal cap, and that max_buffer_bytes stays unchanged.

cc
2026-04-24 19:32:51 -06:00
vicrodh d616a0796a feat(playback): apply MemoryProfile to V2 prefetch (#331)
Wire qbz-core's detected MemoryProfile into the V2 prefetch loop so
memory-constrained hosts (Pi 3B and similar, < 2 GB RAM) cap their
in-memory footprint:

- V2_PREFETCH_COUNT becomes a function reading profile.prefetch_count
  (5 on Normal, 1 on LowMemory). Reading it once per loop call is
  cheap — OnceLock-cached behind the scenes.
- V2_MAX_CONCURRENT_PREFETCH likewise becomes a function; the
  semaphore lazy_static initializes from it once at process start.
- Effective prefetch quality gets a low-memory cap before the
  hardware-rate ladder runs. When profile.allow_hires_prefetch is
  false we drop UltraHiRes/HiRes to Lossless so each cached track
  stays under ~15 MB instead of ~60 MB. The hardware downgrade
  still runs after, so a low-memory + 48 kHz-only DAC still gets
  the right rate.
- lib.rs forces an early call to memory_profile() in run() so the
  resolved profile is logged at boot rather than at first prefetch.

For codehd7's reported case (Pi 3B, 1 GB, HifiBerry Digi2 Pro,
issue #331): the active profile becomes prefetch_count=1,
max_concurrent_prefetch=1, allow_hires_prefetch=false. Cached
prefetch footprint drops from ~5 × 60 MB = 300 MB to a single
~15 MB Lossless track — well within the headroom that 1 GB minus
WebView (~150 MB) leaves.

cc
2026-04-24 19:26:21 -06:00
vicrodh 134808c880 feat(audio): route libasound errors through log::debug
libasound's default error handler prints directly to stderr. During
device enumeration probes, PCMs that wrap dmix or surround routes emit
benign errors ("unable to open slave", "no matching channel map") that
cluttered the app log without indicating a real problem.

Install a process-wide snd_lib_error_set_handler shim from
AudioBackendFactory::create_backend (idempotent via std::sync::Once)
that forwards the file/line/function/err/fmt tuple to log::debug. The
messages stay available with RUST_LOG=debug and disappear during
normal runs.

Adds alsa-sys 0.3 as a direct Linux-only dependency on qbz-audio
(previously transitive via alsa/cpal). The handler is declared
non-variadic and transmuted into libasound's variadic function pointer;
ignoring the unused varargs is ABI-compatible on every platform we
support.

cc
2026-04-24 13:40:09 -06:00
vicrodh 14b72b0582 chore(deps): bump rand from 0.8.5 to 0.8.6
Fixes GHSA-cq8v-f236-94qc (low: rand unsound with custom logger using
rand::rng()). Transitively bumped across both src-tauri and crates
workspaces via cargo update.

cc
2026-04-24 12:05:04 -06:00
vicrodh 674771b216 chore(deps): bump notify-rust from 4.14.0 to 4.16.0 in /src-tauri
cc
2026-04-24 11:40:51 -06:00
vicrodh 40189baade chore(deps): bump tauri-plugin-single-instance from 2.4.0 to 2.4.1 in /src-tauri
cc
2026-04-24 11:38:42 -06:00
vicrodh 592c70928f chore(deps): bump uuid from 1.23.0 to 1.23.1 in /src-tauri
cc
2026-04-24 11:38:32 -06:00
vicrodh 777a060fd5 chore(deps): bump ashpd from 0.13.8 to 0.13.10 in /src-tauri
cc
2026-04-24 11:38:24 -06:00
vicrodh a2e5f8567a chore(deps): bump open from 5.3.3 to 5.3.4 in /src-tauri
cc
2026-04-24 11:38:15 -06:00