Commit Graph
1336 Commits
Author SHA1 Message Date
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 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 1d173a2cb1 feat(downloads): default show_in_library to true for new installs
Offline-cached Qobuz tracks should appear in the Local Library on a
fresh install — the whole point of caching is to have them available
alongside local files. Users who set the preference explicitly (on or
off) are untouched: the INSERT OR IGNORE in DownloadSettingsStore
only writes the default row when no row exists yet.

Three coordinated changes inside download_settings.rs:

  * DownloadSettings::default().show_in_library: false → true
  * Schema DEFAULT on the column: 0 → 1 (covers rows added post-
    migration from code paths that skip the explicit INSERT)
  * The INSERT OR IGNORE seed row now interpolates
    default_settings.show_in_library instead of hardcoding 0, so the
    code default and the SQL default stay in sync.

cc
2026-04-21 15:01:02 -06:00
vicrodh 1ded75b35e perf(image-cache): normalize Plex token out of cache keys for hits across sessions
Plex thumbnail URLs carry X-Plex-Token (and sometimes
X-Plex-Client-Identifier) as a query parameter. Those rotate each
time a token refresh lands, so the *exact same* /library/metadata/…/
thumb resource hashes to a different cache key between sessions, and
the backend dutifully re-downloads and re-caches the same bytes under
the new hash. On a collection that's mostly Plex (the user's 67-item
case), that means every open re-pulls 67 images over the network —
which is indistinguishable from "caching isn't working".

Normalize the cache key on both sides, keeping the original URL for
the actual authenticated fetch:

  * src-tauri: cache_key_for() strips X-Plex-Token and
    X-Plex-Client-Identifier before hashing. The `url` passed to
    reqwest is still the original with token, so the GET authenticates
    correctly; only the on-disk cache key is stable across sessions.
  * frontend: same normalization in imageCacheService.cacheKeyFor()
    covers the in-memory resolvedUrls map, pendingRequests dedup,
    getResolvedIfCached sync hit, and preloadImages "skip if already
    cached" short-circuit.

Non-Plex URLs are untouched — no '?' means no normalization work.
Qobuz's static.qobuz.com CDN URLs don't carry session params, so they
already cache correctly and this is a no-op for them.

cc
2026-04-21 14:24:51 -06:00
vicrodh 5d2e6c2e23 feat(playlist-detail): render Plex tracks added to a Qobuz playlist
Final piece of the Plex-in-playlist plumbing. PlaylistDetailView now
loads the (ratingKey, position) pairs from playlist_plex_tracks and
hydrates metadata from plex_cache_tracks, then interleaves the results
with Qobuz and local tracks by playlist_position so the ordering the
user sees matches what got stored when they added each track.

Backend:
  * plex_cache_get_tracks_by_keys(rating_keys) in plex/mod.rs returns
    PlexTrack metadata for a set of ratingKey strings, skipping keys
    the cache doesn't know about.
  * v2_plex_cache_get_tracks_by_keys Tauri command exposing it,
    registered in lib.rs.

Frontend:
  * New PlaylistPlexTrack interface + plexTracks state alongside the
    existing localTracks pipeline.
  * loadPlexTracks() does the two-step hydrate (positions, then meta).
    Called in parallel with loadLocalTracks and loadPlaylist.
  * plexTrackToDisplay() converts to DisplayTrack. The frontend id is
    derived from the ratingKey via a djb2 hash offset into a
    PLEX_DISPLAY_ID_OFFSET-anchored negative range — stable and never
    collides with local (small negative) or Qobuz (positive) ids.
  * displayTracks interleaves local + plex + Qobuz tracks by position.
    Fast path unchanged when neither source has content.
  * hasLocalTracks, totalTrackCount, totalDuration, and the sidebar's
    hasLocalContent indicator all pick up Plex rows automatically
    because the backend's get_all_playlist_local_track_counts already
    merged the plex count in an earlier commit.

Missing plex cache entries (purged, never hydrated) are silently
dropped instead of rendering placeholder rows — the playlist just
shows fewer Plex tracks until the next Plex cache refresh.

Fixes the bug: Plex tracks can now be added from LocalLibrary and
they show up in the playlist detail view, completely decoupled from
the Qobuz playlist API.

cc
2026-04-21 12:06:48 -06:00
vicrodh 753487582a feat(commands): expose Plex-in-playlist Tauri commands
Wires the qbz-library methods added in the previous commit to Tauri
invoke handlers:

  * v2_playlist_add_plex_track(playlistId, ratingKey, position)
  * v2_playlist_remove_plex_track(playlistId, ratingKey)
  * v2_playlist_get_plex_tracks_with_position(playlistId)

All three lock LibraryState::db, mirror the existing local-track
commands, and go nowhere near the Qobuz API surface — no chance of a
Plex ratingKey being forwarded to /playlist/addTracks by mistake.

Registered in lib.rs invoke_handler next to v2_playlist_add_local_track.

cc
2026-04-21 11:32:22 -06:00
vicrodh 42d432469a feat(cast): v2_{dlna,cast}_play_plex_track for Plex tracks over DLNA/Chromecast
Plex has the same cast routing problem as local library: the Qobuz-only
play_track commands can't resolve Plex ratingKeys. Add dedicated commands
that fetch bytes through plex_resolve_track_media (existing), proxy them
through the cast media server, and load the URL on the renderer.

Proxying through the media server (rather than giving the renderer a
direct Plex URL) keeps the user's Plex token in-process. Renderers would
need the token to fetch from Plex directly and that's not a surface we
want to expose.

Content-type fallback to audio/mpeg when Plex omits it (transcode path),
with a plex_key_to_u64 namespaced hash (high bit set) so Plex ratingKeys
can't collide with Qobuz/library ids in the MediaServer keyspace.

cc
2026-04-20 18:33:46 -06:00
vicrodh 76158c81d3 feat(cast): v2_cast_play_local_track for local-library tracks over Chromecast
Companion to v2_dlna_play_local_track. Same bug, same fix applied to the
Chromecast protocol branch: reads library row + file, infers content_type,
registers bytes with the shared media server, loads the URL on the
Chromecast receiver. Reuses the local_track_content_type helper.

User flagged this on the #332 thread — same Qobuz-only assumption in the
Chromecast play command as in the DLNA one.

cc
2026-04-20 18:26:58 -06:00
vicrodh 75146d66a9 feat(cast): v2_dlna_play_local_track for local-library tracks over DLNA
Mirrors v2_dlna_play_track but for library row ids. Reads the file from
disk, infers content_type from AudioFormat (with extension fallback for
Unknown), registers bytes with the DLNA media server, sends AVTransport
URI + Play to the renderer.

Without this command the cast path had no way to send local audio to a
DLNA device — v2_dlna_play_track resolves the id as a Qobuz track and
fails (404 in offline sessions, wrong-song match in online sessions where
the library row id happens to collide with a real Qobuz track id). Issue
#332 reported the symptom as "DLNA not operational"; logs showed the
renderer connected successfully but never received a SOAP SetAVTransportURI.

Frontend wiring in the cast dispatch + playbackService guard removal lands
in follow-up commits.

cc
2026-04-20 18:23:18 -06:00
vicrodh 23f56b178a feat(mixtape): v2_enqueue_collection_item + Plex branch + repo hydration
Backend:
- New v2_enqueue_collection_item(collectionId, position, mode) routes a
  single collection item through the existing ProdItemResolver — same
  path v2_enqueue_collection uses, scoped to one position. Supports
  replace / play_next / append modes. Unlocks per-album Play for local
  and Plex items from the Collection detail view (previously only Qobuz
  had a working fast-path there).
- enqueue.rs: resolve_local_album now detects plex: prefixes and routes
  to plex_cache_get_album_tracks instead of querying local_tracks, so
  Collections with Plex albums actually enqueue end-to-end.
- repo.rs: list_collections now hydrates items for every collection so
  the item count in CollectionsView / MixtapesView is real, not 0.
- mixtapeCollectionsStore.ts: bindings updated for new + changed
  commands (add_item with allow_duplicate, item_exists, custom cover
  upload/remove).

cc
2026-04-20 17:47:38 -06:00
vicrodh 1a223c2a85 feat(mixtape): custom-cover upload + remove commands
Lets users pick any image (png/jpg/jpeg/webp) as a Mixtape's or
Collection's cover. Mirrors v2_library_set_custom_album_cover:
source → artwork cache dir → resize to 1000x1000 via image crate →
store path in mixtape_collections.custom_artwork_path.

- v2_mixtape_upload_custom_cover(id, source_path): validates extension,
  copies + resizes, stamps the resulting path, deletes the previous
  cover file after the DB write succeeds.
- v2_mixtape_remove_custom_cover(id): nulls the column + deletes the
  on-disk file.
- Both registered in lib.rs.
- Frontend store: uploadCustomCover / removeCustomCover wrappers that
  also patch collectionsStore optimistically so UIs update without a
  full reload.

Uses mixtape::repo::get_custom_artwork (added in the previous commit)
to find the previous path before overwriting.

cc
2026-04-19 22:22:14 -06:00
vicrodh f01fc17383 feat(queue): clear-queue includeCurrent flag stops + clears now-playing
QueueManager::clear previously always preserved the current track;
plus bridge.play_index only mutates queue state, never triggers
audio. Result: when the user hit Clear Queue with nothing playing,
a stale track stayed in NOW PLAYING across app restarts.

- QueueManager::clear(keep_current: bool) — existing callers pin to
  true (legacy behavior). New test covers the false path.
- QbzCore::clear_queue / CoreBridge::clear_queue take the flag through.
- v2_clear_queue accepts an optional includeCurrent flag from the
  frontend; when true, calls bridge.stop() first so the audio pipeline
  actually stops, then wipes the current slot too.
- qconnect sync path + qbzd daemon pin keep_current=true (no behavior
  change).

cc
2026-04-19 21:17:09 -06:00
vicrodh de96afccd5 feat(plex): parse and cache track year + genre
Plex exposes album release year (parentYear / parentOriginallyAvailableAt)
and genre (inline attr or nested <Genre tag>). The parser dropped both;
LocalLibrary and DiscographyBuilder could never show a Plex album's year
even though the metadata was available upstream.

- Extend PlexTrack + PlexCachedAlbum structs with year/genre.
- Parse both in parse_track_block, including the ISO-date fallback.
- ALTER TABLE plex_cache_tracks ADD COLUMN year INTEGER / genre TEXT
  (idempotent).
- INSERT + SELECT paths + the album aggregation carry the new columns.

cc
2026-04-19 21:15:46 -06:00
vicrodh 2f769d38cd fix(mixtape): hydrate items in list_collections
list_collections only read the mixtape_collections row and left
items=[], so every consumer (sidebar, Collections/Mixtapes grid,
AddToMixtapeModal) saw zero-item counts for every collection. Load
items per collection at the end of the listing, matching what
get_collection already does.

cc
2026-04-19 21:14:28 -06:00
vicrodh 2daff794a8 feat(mixtape): queue_source_collection_id session tracking
In-memory Option<String> tracks which Mixtape/Collection the current
queue was built from. Set by v2_enqueue_collection, cleared by any
non-Mixtape queue replacement (set_queue / clear_queue). Append-style
ops (add/add_next/bulk) preserve the context.

Persistence across restart is deferred — the session_queue_state
table does not exist yet; when it arrives, the ALTER in
mixtape::schema will add the source_collection_id column automatically,
and this state just needs a read+write on the appropriate bootstrap
and commit points.

cc
2026-04-18 18:47:07 -06:00
vicrodh a13de3b369 feat(mixtape): v2_skip_to_next_item and v2_skip_to_previous_item
Two commands that read the current queue + index + elapsed time from
the runtime and call next_item_index / previous_item_index helpers
(already tested in mixtape::enqueue) to compute the target seek
position. Boundary detection uses source_item_id_hint with album_id
fallback, so these also work for queues built via "play album"
outside the Mixtape flow.

Commands are registered and invocable from Tauri but not wired to
any frontend control — per spec §4.5, NowPlayingBar changes are
deferred to a future spec.

cc
2026-04-18 18:41:02 -06:00
vicrodh c6b256470a feat(mixtape): enqueue resolver and v2_enqueue_collection
Polymorphic resolver handles album / track / playlist x qobuz / local
variants; each maps to the canonical QueueTrack the existing "play
album" paths use. Album shuffle applied at enqueue time, tracks
within each item stay in their native order. source_item_id_hint
stamped centrally so skip-to-item boundary detection (next task)
works consistently.

Includes next_item_index / previous_item_index helpers with unit
tests for both the exact-hint and album_id-fallback cases.
local_track_to_queue_track converts LocalTrack sample_rate from Hz
to kHz and wraps artwork_path as a file:// URL.

cc
2026-04-18 18:33:53 -06:00
vicrodh b539f67b7c feat(mixtape): Tauri command layer for CRUD
Twelve v2_* commands wrap mixtape::repo with the standard app state
pattern used by commands_v2/playlists.rs. Enum values travel as
strings across the Tauri boundary; camelCase for frontend, snake_case
for Rust.

Also adds LibraryDatabase::with_connection_mut for the reorder command,
which needs a transaction (&mut Connection).

cc
2026-04-18 18:04:08 -06:00
vicrodh c55a208215 feat(mixtape): CRUD repository for collections and items
Full surface for create/get/list/rename/description/play_mode/kind/
delete/touch_play on collections and add/list/remove/reorder on items.
Dedup on exact (source, source_item_id) within a collection. Artist
collection kind is locked (cannot be converted to/from). Cascade
delete covers items. Position compaction on remove; atomic transaction
on reorder.

Covered by 10 unit tests using in-memory SQLite.

cc
2026-04-18 17:57:41 -06:00
vicrodh 357650300d feat(mixtape): schema migrations for collections and items
Two new tables (mixtape_collections, mixtape_collection_items) with
CASCADE on delete and indexes for the two common query patterns. One
additive column on session_queue_state for queue-source tracking.

Idempotent (CREATE TABLE IF NOT EXISTS, ALTER only when missing),
safe on every app start. The ALTER is skipped gracefully if
session_queue_state hasn't been created yet.

Adds LibraryDatabase::with_connection() helper (qbz-library) so
src-tauri can run external DDL on the shared Connection without
exposing it as a field. Migration is wired in session_lifecycle
immediately after library.init_at() in both the online and offline
activation paths.

cc
2026-04-18 17:46:13 -06:00
vicrodh b2fb251783 feat(queue): add source_item_id_hint to QueueTrack
Reserved for upcoming Mixtape/Collection enqueue paths to mark item
boundaries. Existing enqueue paths populate it with the track's
album_id as a safe fallback so skip-to-item commands behave
correctly even for queues built via "play album" today.

cc
2026-04-18 17:23:59 -06:00
vicrodh 8314b955c4 fix(credentials): defensive keyring handling with timeout and session latch
Linux Secret Service (GNOME Keyring / KWallet) can block indefinitely on
an unlock dialog when the user's login keyring is out of sync with their
session password. Each credential touch (load at bootstrap, save after
login, save-credentials on password path) used to produce its own dialog,
so every login triggered 3-4 prompts the user had to dismiss before the
app could continue (issue #329).

Make credentials/mod.rs defensive:

- Every keyring operation runs on a worker thread with a 2.5 s wall-clock
  timeout (run_with_keyring_timeout). If the user is staring at an
  unlock dialog they can't satisfy, control flow continues after the
  timeout while the worker stays blocked in the background.

- A process-wide atomic (KEYRING_STATE) latches to BROKEN on the first
  failure or timeout. Subsequent keyring_get / keyring_set /
  keyring_delete calls short-circuit and the app falls back to the
  encrypted file path for the rest of the session. One prompt max per
  process. NoEntry does not count as a failure.

- The encrypted file in the config directory is the single source of
  truth for every credential. save_qobuz_credentials already worked this
  way; save_oauth_token was inverted from keyring-first to file-first so
  the keyring is no longer on the success path of OAuth login.

- Load paths try the keyring first (cheap when it works) and fall back
  to the file; the obsolete "migrate file to keyring" branches are gone.

Public API is unchanged. All existing callers compile unmodified. Existing
encrypted files and keyring entries remain readable; no file-format
changes.

qbz-secrets (offline-cache master key) is deliberately untouched — adding
a KDF fallback there without a migration plan would make existing
BACKEND_MARKER_KEYRING blobs undecipherable. Filed as Phase 2 in the
analysis doc at qbz-nix-docs/bugfix/2026-04-18-issue-329-keyring-unlock-dialog.md.

Fixes #329.

cc
2026-04-18 13:18:48 -06:00
vicrodh a7e2a8e911 feat(ui): animated padlock on track row while offline CMAF decrypt runs
User feedback from live testing: even with gapless working correctly,
the cold first-play of an offline CMAF bundle takes ~500ms to decrypt
on AES-NI hardware, 5-6s on the software fallback. No visible
indicator of what was happening — UX felt like the app was stalling.

Solution: when a track is being decrypted, replace its play glyph in
the track row with an animated padlock (lucide-svelte Lock icon +
CSS keyframe shake/scale). Tells the user "we're unlocking your
content", which is literally what's happening.

Implementation:

- Backend: new helper `load_cmaf_bundle_with_ui_events` wraps the
  spawn_blocking decrypt with two Tauri events:
  - `offline:unlock_start { trackId }`
  - `offline:unlock_end   { trackId, success }`
  The helper takes two ids: `display_track_id` (what the frontend
  knows this track as — Qobuz id or library row id depending on
  flow) and `cmaf_track_id` (the Qobuz id used to key the bundle
  inside load_cmaf_bundle itself). Same track shows the animation
  whether clicked from Qobuz view or Local Library.
- All three v2 decrypt call sites now route through the helper:
  v2_play_track offline branch, v2_play_next_gapless (both the
  Qobuz-id and Library-qobuz_download branches), and
  v2_library_play_track.
- Frontend: new store `unlockingStore` maintains a `Set<trackId>`
  updated from the events, with subscribe/isUnlocking API. Started
  from +page.svelte alongside the existing player/queue pollers.
- TrackRow.svelte: new `isUnlocking` reactive state derived from
  the store, a new branch in the play-number template that renders
  the Lock icon with `lock-shake` animation, scoped CSS keyframes
  (qbz-unlocking — slight rotate + scale + opacity pulse at 1.2s
  loop, accent-primary colour, cursor: progress).

The animation fires on cold decrypt only; warm L1/L2 hits bypass the
decrypt entirely and don't emit the events, so the user never sees
the padlock for a cached track — correct behavior.

cc
2026-04-17 14:11:44 -06:00
vicrodh 38390db408 fix(offline-cache): persist artwork_path so library UI shows cover art
Qobuz-cached tracks (both v1 legacy and new v2 bundles) weren't
showing album art in Local Library. Root cause was a pair of bugs:

1. Legacy v1 path DID download cover.jpg to the organized album
   directory (via save_album_artwork) but passed None as artwork_path
   when inserting into the library index. So the file existed on
   disk but the DB row didn't point at it — the UI had no way to
   find it. Pre-existing bug, not introduced by the v2 work.

2. v2 CMAF bundle path didn't save artwork at all. Worked fine for
   encrypted playback but the tracks-cmaf/<id>/ directory had no
   cover.jpg for the UI to fall back to.

Fix: in both paths, call save_album_artwork next to the cached
content (organized FLAC parent for v1, tracks-cmaf/<id>/ for v2),
then pass the cover.jpg path to insert_qobuz_cached_track_with_
grouping's artwork_path parameter. Library rows now carry the
on-disk path to the artwork and the UI picks it up just like for
user-owned local FLACs.

New downloads will have artwork immediately. Tracks already in the
cache from before this commit will remain artwork-less until
re-downloaded — acceptable since the user can just re-trigger
download per track if they want the art back, and the 30-day
subscription wipe refreshes everything eventually anyway.

cc
2026-04-17 13:35:57 -06:00
vicrodh 6d2d69d903 fix(library): gapless for Qobuz-cached tracks in Local Library
Library auto-advance had a ~6s gap between two Qobuz-cached v2 tracks.
Tracks within Qobuz flow had gapless working because those pass the
Qobuz track id to v2_play_next_gapless and the offline cache is keyed
by that id. From Local Library the frontend passes the LIBRARY row id
(small autoincrement, e.g. 542), so:

- L1 keyed by library row id → miss (prefetches key by Qobuz id)
- L2 keyed by library row id → miss
- offline cache get_cmaf_bundle(542) → miss (cache is keyed by Qobuz id)
- library fallback: file_path for qobuz_download v2 tracks is a
  directory, std::fs::read blows up → gapless returns Ok(false)

Result: gapless declined, track ended, frontend called play_track as
normal transition, which went through v2_library_play_track's full
decrypt path from cold → 5-6s silence.

Fix: in the library-fallback branch of v2_play_next_gapless, detect
source='qobuz_download' and re-query the offline cache by the row's
qobuz_track_id. If a v2 bundle exists, decrypt it on the blocking
pool and feed to play_next using the LIBRARY row id (so the player
keys match what the frontend is waiting for — same pattern as the
earlier library track-id fix). Warm L1 keyed by library row id too,
so next transition between tracks in this library view hits memory
instead of re-decrypting.

This leaves the v1 path working (plain FLAC at segments_path) and
the regular user-owned local FLAC path unchanged.

cc
2026-04-17 12:30:29 -06:00
vicrodh f95199d32b fix(library): pass library row id (not Qobuz id) to play_data
Live testing from Local Library with a v2 CMAF-cached Qobuz track
showed a cascade of UI breakage: seekbar never appeared, track never
auto-advanced, queue-manager fired next_track every 1s for 30+
iterations in a loop, currently-playing song card stayed on the first
track. Audio was playing fine; the UI just couldn't see it.

Root cause: my v2_library_play_track CMAF path was passing the Qobuz
track id (from the library row's qobuz_track_id field) to
player.play_data. Every other piece of the UI state machine — queue
sync, seekbar progress events, auto-advance trigger, now-playing card
— keys off whatever id the frontend invoked with, which is the
library row id (e.g. 542), not the Qobuz id (e.g. 95787326). So the
player was emitting updates about "playing 95787326" while the
frontend was waiting for updates about 542 that never came.

Fix: play_data now takes track_id (library row id) on both the v2
CMAF decrypt branch and the v1 plain-FLAC branch. The offline-cache
DB lookup still uses qobuz_track_id (that DB is keyed by Qobuz id),
the player gets the library id. AudioCache insert also keyed by
library id so Library-side replay hits L1 correctly.

cc
2026-04-17 12:11:16 -06:00