Commit Graph
1531 Commits
Author SHA1 Message Date
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
vicrodh 93a908ee73 fix(offline-cache): check L1/L2 before offline tier in gapless
Live testing showed gapless between two offline-cached v2 tracks still
had a 6s gap even with the L1 warming + spawn_blocking + track_id fixes.
Root cause: v2_play_next_gapless checked the offline cache FIRST, which
meant for tracks that were BOTH on disk AND already in L1 (prefetched
from the CDN into memory), we took the slow offline decrypt path even
though instant bytes were sitting in RAM.

Log evidence:
  16:52:09 Prefetch put track 47683552 in L1 (61MB)
  16:53:00 play_next_gapless for 47683552
  16:53:06 [V2/GAPLESS] Track 47683552 from OFFLINE cache (CMAF v2)
           (decrypted 58MB — took ~5s while L1 had the bytes)
  16:53:06 Gapless: no engine, ignoring PlayNext
           (engine dropped at 16:53:05 when old track ended)

The very next transition (47683552 → 47683553), where 47683553 happened
to be ONLY in L1 and not in the offline cache, worked perfectly:
  16:55:58 [V2/GAPLESS] Track 47683553 from MEMORY cache (72MB) ← 60ms
  16:56:02 Gapless transition: track 47683552 -> 47683553 (clean)

Fix: reorder tiers to L1 → L2 → offline → library. The offline v2
decrypt path is expensive precisely because we already warm L1 after
every decrypt — so the second time around (or whenever prefetch beat
us to it), L1 hit is instant. The offline tier is correctly reserved
for the "truly cold" case: track on disk but not in memory.

Also added a "warm L1 from L2" step so a disk-cache hit also populates
memory, matching the warm-L1-on-decrypt pattern the offline path
already did.

cc
2026-04-17 11:14:18 -06:00
vicrodh 49d132b6c2 fix(offline-cache): warm L1 memory cache after v2 CMAF decrypt
Live testing showed v2 offline tracks had a 3-6s gap on gapless
transitions even after the track_id + spawn_blocking fixes, because
the decrypt itself takes 5-7 seconds for a typical 60-200MB FLAC
(disk read of encrypted segments.bin + AES-CTR per-frame decrypt is
slower than the track's natural tail). By the time load_cmaf_bundle
returns, the previous track has ended and the player's gapless engine
has been dropped.

Workable only second time: once a track's decrypted bytes are in L1
memory cache, subsequent accesses are ~60ms. That's what happened on
the second transition we observed — prefetched tracks hit L1 for
free, so gapless landed in time.

Fix: after successfully decrypting a v2 bundle, insert the resulting
plain-FLAC bytes into the AudioCache (L1) before handing them to the
player. Applies to three call sites:

- v2_play_track offline cache branch → warm on first play
- v2_play_next_gapless CMAF v2 branch → warm on gapless success
- v2_library_play_track CMAF v2 branch → warm on Library click

Next access to the same track (replay, scrub, gapless re-queue,
upcoming-track peek) is now a <100ms L1 hit regardless of the v2
cache_format the row has. The decrypt cost is paid once per track
per session, not once per access.

This still leaves the "first play of an offline track" taking 5-7s
before audio starts. That's a separate optimization — candidates are
(a) offline-track prefetch that pre-decrypts upcoming tracks in the
background, (b) storing decrypted FLAC in L2 disk cache so cold
restarts don't have to decrypt, (c) speeding up the decrypt itself.

cc
2026-04-17 10:26:13 -06:00
vicrodh 70deae6722 fix(offline-cache): track-id mapping + spawn_blocking decrypt
Two bugs the live testing surfaced.

1. v2_library_play_track was passing the library row id (autoincrement
   small integer) to the offline-cache DB lookup. The offline cache is
   keyed by the Qobuz track id (the big id like 95787327). So the
   lookup always missed and my earlier "orphan" error fired for EVERY
   v2 track clicked from Local Library. Fix: read qobuz_track_id from
   the library row and use THAT for the offline-cache lookup.

2. load_cmaf_bundle is synchronous (std::fs::read + AES-CTR decrypt +
   FLAC assemble). When called directly from an async fn on the tokio
   executor, a 200MB offline track took ~22 seconds end-to-end during
   concurrent prefetch activity — because the sync work starved the
   executor thread, delaying the queue's auto-advance event, which
   fired play_track as a fallback AFTER the old track stopped (which
   killed the gapless engine). Net: 6-8 second audible gap between
   two offline-cached tracks even though the bytes were on disk.
   Fix: wrap load_cmaf_bundle in tokio::task::spawn_blocking in all
   three call sites (v2_play_track, v2_play_next_gapless,
   v2_library_play_track). The executor stays responsive, gapless
   finishes before the engine drops, transition is clean.

Together these close the two loudest symptoms from the live session:
"Is a directory" when playing v2 from Library, and the seconds-long
gap between consecutive v2 tracks even with gapless enabled.

cc
2026-04-17 10:07:49 -06:00
vicrodh 8238ce4de4 fix(offline-cache): route v2 bundles through CMAF in library + gapless
Two symptoms with the same root cause — code paths that touched the
offline cache read file_path as "a plain FLAC file" and handed it
straight to std::fs::read + Symphonia. For v2 bundles the file_path is
the encrypted segments.bin, so both paths exploded.

1. v2_library_play_track: error "Is a directory (os error 21)" when
   clicking a v2-cached track from Local Library.
   Fix: for source='qobuz_download' rows, the offline-cache DB is the
   authoritative source of file paths, not the library index (library
   carries display metadata only). Query get_cmaf_bundle:
     - cache_format=2 → load_cmaf_bundle (decrypt + play)
     - cache_format=1 → read row.segments_path (which for v1 IS the FLAC)
     - no row → clean error instead of reading the library directory

2. v2_play_next_gapless: "Symphonia probe failed: end of stream" with
   ~6 failed retries per track transition → ~6s gap between two v2
   offline-cached tracks.
   Fix: same cache_format branching as v2_play_track already does.
   Reuse load_cmaf_bundle so the probe sees plain FLAC bytes, not
   encrypted mp4 boxes.

The prefetch AES-CBC unpad error during concurrent downloads is the
other piece of today's gap story — session race in ensure_cmaf_session.
That's a separate fix in the next commit.

cc
2026-04-17 09:49:54 -06:00
vicrodh 8ab8fc5dea fix(offline-cache): emit progress events from CMAF download path
The UI's offline-download progress ring listens to
'offline:caching_progress' events. The legacy StreamFetcher::fetch_to_file
emits those per HTTP chunk via app_handle. The new CMAF path went
silent because cmaf_download_raw lives in the qbz-qobuz crate with no
Tauri access — the user saw the completion toast but never saw the
ring fill.

Fix:
- Add an optional CmafProgressCallback to the qbz_qobuz::cmaf public
  API. Typed as Arc<dyn Fn(CmafProgressUpdate) + Send + Sync>.
- New download_full_with_progress and download_raw_with_progress
  variants; the callback-less versions delegate to them with None.
- fetch_all_segments fires the callback once per completed segment
  with cumulative (k, n, bytes_this_segment). Callback is invoked on
  the worker thread after the bytes land, before the rate-limit
  cooldown.
- try_cmaf_offline_download passes a callback that emits
  'offline:caching_progress' events in the exact shape the UI already
  expects (trackId, progressPercent 0..100, bytesDownloaded,
  totalBytes: null, status: 'downloading'). Also emits one
  zero-progress event up front so the ring doesn't sit at "queued"
  while the first segment fetches.

Percentage is computed as (segments_completed / n_segments) * 100,
rounded and clamped. It's segment-granular rather than byte-granular
so the ring advances in N discrete steps (typically 8-20 for a 4-min
track) rather than smoothly — matching what the CMAF pipeline can
actually observe, which is segment-complete events.

cc
2026-04-17 09:01:22 -06:00
vicrodh aa111e15b3 feat(offline-cache): route library playback through CMAF + full purge
Two loose ends after v2 landed:

1. Local Library integration. Qobuz-cached offline tracks already show
   up in the Local Library view (via insert_qobuz_cached_track_with_
   grouping with source='qobuz_download'). v2_library_play_track was
   reading track.file_path and doing std::fs::read() — that works for
   plain-FLAC v1 entries, but for v2 the path is the bundle directory,
   not a playable file. Fixed:
   - Extract the CMAF-load-and-decrypt logic into a shared module
     (offline_cache::playback::load_cmaf_bundle) used by both
     v2_play_track and v2_library_play_track.
   - v2_library_play_track checks source='qobuz_download' +
     cache_format=2 on the row and routes through the shared helper.
     Legacy v1 entries and regular user local files unchanged.

2. purge_all_cached_files. Previously deleted individual file_path
   entries only. For v2 the file_path points inside a track dir
   (segments.bin) — removing just that file orphans init.mp4 and
   manifest.json. Fixed:
   - When the file_path looks like tracks-cmaf/<id>/segments.bin
     (matched by the path shape, not a schema flag), remove the whole
     enclosing <id>/ directory.
   - Belt-and-suspenders: also delete the top-level tracks-cmaf/ dir
     after the per-row pass, so orphaned bundles from corrupted rows
     are swept up too.

Net: Local Library view preserves the "cached Qobuz tracks appear
here" UX, AND the 30-day grace wipe still removes every encrypted
segment + every library row in one atomic operation. No contradiction
between the two behaviors.

Also extends the compliance whitepaper with a section explaining the
Local Library integration so Franck's team can see that the wipe
contract isn't worked around by the UX shortcut.

cc
2026-04-17 08:53:13 -06:00
vicrodh b3ad548a9c feat(subscription-gate): extend offline grace window 3 → 30 days
Matches Qobuz's own mobile app posture. The previous 3-day window was
both user-hostile (punishes anyone on a flaky network) and a compliance
red flag (shorter windows read as 'we're hiding something', longer
windows read as 'we're lenient for a reason'). 30 days is the exact
parity answer.

The useful protection against bulk-download-and-ghost attacks comes
from the CMAF-at-rest offline cache (v2 format), not from aggressive
revalidation. This grace window is a belt over the suspenders.

Changes:
- GRACE_PERIOD_SECS: 3 → 30 days in config/subscription_state.rs, with
  a comment explaining the reasoning for future readers.
- Log message in session_lifecycle no longer says "3 days" (it quoted
  a constant, now says "grace window").
- Disclaimer string in all 5 locales (en/es/de/fr/pt) updated from
  "3 days" to "30 days" so the Offline Playback settings screen
  reflects the actual behavior.

No behavior changes for users with an active subscription. Users who
were within the old 3-day window don't lose cache — the window
extended, not contracted.

cc
2026-04-17 08:28:00 -06:00
vicrodh db6e7021ec feat(offline-cache): play v2 CMAF bundles from disk
Closes the playback side of the offline hardening. v2_play_track's
offline-cache-hit branch now queries the new CMAF bundle columns via
get_cmaf_bundle(), branches on cache_format:

- cache_format = 2 (new): read init.mp4 + segments.bin + manifest.json,
  unwrap the content_key through qbz-secrets, decrypt frames via the
  shared qbz_qobuz::cmaf::decrypt_segments_into, assemble into a
  plain-FLAC Vec<u8>, and feed it to player.play_data — exactly the
  same interface the player has always seen for cache hits.

- cache_format = 1 (legacy): unchanged — read the plain FLAC file at
  segments_path (the old file_path column) and play directly.

Any failure on the v2 path (missing init, wrong-size unwrap, decrypt
error, vault init failure) logs a warning and falls through to the
next cache tier (memory, disk playback cache) or the network — same
degradation model as a stale legacy entry.

load_cmaf_offline_bundle() is isolated in playback.rs so the hot
v2_play_track body stays readable; it owns the "convert a CmafBundleRow
from the DB into audio_data for the player" pipeline.

LoadedBundle in offline_cache/cmaf_store gets a decrypt_to_flac method
so the caller doesn't have to duplicate the "parse init + concat header
+ decrypt segments" dance the streaming path already does.

End-to-end for v2:
  download  → cmaf::download_raw → persist_bundle + wrap key → DB
  play      → get_cmaf_bundle   → read_bundle → unwrap key → decrypt

cc
2026-04-17 08:22:47 -06:00
vicrodh 8d9705dea0 feat(offline-cache): rewire download path to CMAF-first
Closes the last legacy /track/getFileUrl user-facing hole flagged by
the CMAF audit. spawn_track_cache_download now tries the CMAF path
first; only falls through to /track/getFileUrl if any of the CMAF
steps fail (no CoreBridge yet, /file/url returns a non-CMAF response,
network hiccup, SecretBox init failure, etc.).

On CMAF success the on-disk layout is:

    <offline_root>/tracks-cmaf/<track_id>/
        init.mp4        — unencrypted container with FLAC header
        segments.bin    — concatenated encrypted audio segments
        manifest.json   — segment offsets (belt-and-suspenders recovery)

The per-track AES content key and session infos are wrapped via
qbz-secrets::SecretBox before they touch the SQLite row
(content_key_wrapped, infos_wrapped columns from the previous commit).
cache_format flips to 2 on the row; the library-index entry lands
with the same title/artist/album the legacy path used, so the UI is
unchanged.

What is deliberately NOT done on the CMAF path:
- No write_flac_tags / embed_artwork: the bundle on disk is encrypted,
  writing FLAC tags would corrupt it. The library row carries the
  metadata the UI actually reads.
- No organize_cached_file: v2 bundles live under tracks-cmaf/<id>/
  by design, no per-artist/album tree (the files aren't standalone
  playable FLACs anyway).

Two new modules in src-tauri/src/offline_cache/:
- cmaf_store: persist/load/remove the on-disk bundle layout + the
  recovery manifest. I/O only, no crypto, no network.
- secret_vault: lazy SecretBox singleton scoped to the offline-root
  directory. The SecretBox handle is cached in OnceLock so every
  download shares the same keyring entry (or KDF master key).

Existing offline entries keep working — they're cache_format=1, so
they read from the plain-FLAC file_path unchanged. Playback-from-v2
comes in the next commit (task #20).

Legacy fallback still writes tags + organizes files exactly like
before. This keeps users whose v1 cache is still useful unblocked
while the v2 path takes over fresh downloads.

cc
2026-04-17 08:17:18 -06:00
vicrodh 935b3f81e0 feat(offline-cache): schema migration for v2 CMAF bundle storage
Adds columns to cached_tracks for the v2 format that stores Qobuz's raw
CMAF segments bit-identical on disk, with the per-track AES content key
wrapped via qbz-secrets (OS keyring or KDF fallback).

New columns (added via idempotent ALTER TABLE, safe to re-run):
- cache_format    INTEGER DEFAULT 1   — 1 = legacy plain FLAC (existing),
                                          2 = raw CMAF bundle (new)
- init_path       TEXT                — path to init.mp4 (FLAC header +
                                          segment table)
- content_key_wrapped BLOB            — wrapped 16-byte AES content key
- infos_wrapped       BLOB            — wrapped session infos salt
- format_id       INTEGER             — Qobuz format id (5/6/7/27)
- n_segments      INTEGER             — number of audio segments

Existing rows keep cache_format=1 and continue to play from the plain
FLAC at file_path — zero regression for users with populated caches
when the upgrade ships. New downloads will land as cache_format=2. The
two formats coexist until v1 rows age out naturally (user re-download
or subscription-lapse cache wipe).

Adds set_cmaf_bundle() / get_cmaf_bundle() helpers + a CmafBundleRow
struct so the download path and the playback path have one authoritative
DB surface to talk to.

Also adds qbz-secrets as a direct src-tauri dep so the next commit
(rewiring spawn_track_cache_download) can wrap keys right at the
download site.

cc
2026-04-17 07:30:50 -06:00
vicrodh 391e797c9a refactor(cmaf): lift pipeline into qbz-qobuz + add download_raw
Prep work for offline-cache CMAF hardening. The full CMAF pipeline
(init-segment setup, full-download, decrypt) used to live in
src-tauri/src/commands_v2/helpers.rs — fine for playback but unreachable
from the offline-cache code without dragging Tauri deps around.

Moves:
- try_cmaf_full_download → qbz_qobuz::cmaf::download_full
- try_cmaf_streaming_setup → qbz_qobuz::cmaf::setup_streaming
- CmafStreamingInfo struct → qbz_qobuz::cmaf

Adds:
- qbz_qobuz::cmaf::download_raw + CmafRawBundle — fetches the same track
  but returns the ENCRYPTED segments + init bytes + the unwrapped
  content_key + the session infos, ready to be persisted to disk as-is.
  The offline cache will use this so that cached files are bit-identical
  to what Qobuz delivered (no decrypt-to-disk), with the content key
  stored separately under an OS-keyring-wrapped blob. Coming in next
  commits.
- qbz_qobuz::cmaf::decrypt_segments_into — shared decrypt helper used by
  download_full and (in the next commits) by the offline-playback path
  that reads encrypted segments from disk instead of CDN.

In-tree behaviour is unchanged: try_cmaf_full_download /
try_cmaf_streaming_setup in helpers.rs are now thin wrappers that hand
the CoreBridge's QobuzClient to the crate functions. Same API surface
for playback.rs callers, same log tags, same on-wire behaviour.

v2_cmaf_stream stays in src-tauri because it binds to qbz-player's
BufferWriter + qbz-cache's AudioCache — those are playback-only deps
that don't belong in qbz-qobuz.

Net: -100 LOC out of src-tauri, +390 LOC in a properly-layered crate
module with docs explaining the two variants and why the raw one exists.

cc
2026-04-17 00:46:31 -06:00
vicrodh 6de109b3b8 refactor(labels): purge legacy /label/get stack
/label/get is gone from the Qobuz v9.7.0.3 Android app (source:
c30/b.java doesn't declare it anymore). With the Qobuz beta rollout
the server-side deprecation timer is probably running, so we yank our
dependency now.

Removed:
- LABEL_GET path constant (qbz-qobuz + src-tauri/api)
- get_label() client method (qbz-qobuz + src-tauri/api)
- get_label() wrapper in QbzCore + CoreBridge
- v2_get_label tauri command + its lib.rs registration
- LabelDetail backend type (qbz-models + src-tauri/api/models)
- commands::search::get_label (dead, was never registered)
- Unused LabelDetail import in src/routes/+page.svelte

Preserved:
- Frontend LabelDetail interface (it's a UI view-model shaped by
  LabelReleasesView, not a backend contract)
- /api/labels/{id} HTTP route on qbzd — now composes /label/page +
  /label/getAlbums internally so external HTTP clients still see the
  same { id, name, description, image, albums: { items, total, ... } }
  shape

cc
2026-04-16 22:59:31 -06:00
vicrodh 595b71e4eb feat(api): add 7 label endpoints from v9.7.0.3 spec
Closes the gap between our client and the Qobuz Android app's current
label API (source: c30/b.java). Qobuz is rolling out a web beta so the
legacy /label/get is likely on borrowed time — adding the v9.7.0.3
endpoints now so the migration can happen in follow-up commits.

New endpoints (paths + client + core + bridge + v2 command + registered):
- /label/getAlbums           → v2_get_label_albums
    paginated catalog with sort/order/genre_ids/from_date/to_date
- /label/getNextReleases     → v2_get_label_next_releases
- /label/getAwardedReleases  → v2_get_label_awarded_releases
- /label/getPlaylists        → v2_get_label_playlists
- /label/getTopArtists       → v2_get_label_top_artists
- /label/story               → v2_get_label_story
- /label/getList             → v2_get_label_list (POST, mirrors
    track/getList signing)

New response types in qbz-models:
- LabelListPage<T> — generic { has_more, items: [T], total?, offset?,
  limit? } envelope, tolerant to either V2 list or SearchResultsPage
  shapes until samples land
- LabelStoryResponse, LabelGetListResponse — best-effort with optional
  fields so a shape mismatch is non-fatal

The legacy /label/get + get_label() + v2_get_label + LabelDetail are
retained untouched for now; frontend migration and deletion come next.

cc
2026-04-16 22:48:38 -06:00
vicrodh be70fd4eab Merge branch 'bugfix/internal/queue-shuffle-click-327' into pre-release
Fix #327 — clicking a track in the queue panel now plays that exact
track even under shuffle. Adds play_upcoming_at with shuffle-aware
index resolution, plus dead From-impl cleanup.
2026-04-16 22:26:06 -06:00
vicrodh 5656bbe2ab fix(queue): clicking a track respects shuffle order (#327)
The frontend was resolving queue-panel clicks via
`actualIndex = current_index + trackIndex`, mixing the unshuffled
canonical position with the shuffle-aware display position. On shuffle
mode, the resulting index pointed at a different track than the one
the user clicked, which is exactly what issue #327 reports.

Solution:
- New `QueueManager::play_upcoming_at(upcoming_index)` resolves the
  click position through `shuffle_order` + `shuffle_position` when
  shuffle is on, and falls back to `current_index + upcoming_index + 1`
  otherwise.
- Exposed through `QbzCore::play_upcoming_at` (emits QueueUpdated) and
  `CoreBridge::play_upcoming_at`.
- New `v2_play_queue_upcoming_at` tauri command.
- `QueuePanel.onPlayTrack` now carries the `originalIndex` so the
  caller knows the exact upcoming-list position the user clicked.
- `handleQueueTrackPlay` calls the new command directly — no more
  client-side index arithmetic.

Also deletes the two dead `From<crate::queue::QueueTrack>` impls in
commands_v2/queue.rs (canonical queue lives in qbz-player now), per
the zero-legacy V2 policy.

Tests: two new unit tests cover the linear and shuffle-aware paths.
Session persistence is untouched — the new method only consults
existing state.

cc
2026-04-16 20:04:24 -06:00
vicrodh edededcf8c chore(cast): remove AirPlay subsystem (dead code)
AirPlay was scaffolded in both crates/qbz-cast/src/airplay and
src-tauri/src/cast/airplay but never worked at runtime: the RAOP
sender required an Apple MFi-licensed stack with no free/open
alternative, and the backend state was already disabled via the
commented `.manage(airplay_state)` line in src-tauri/src/lib.rs.
The CastPicker UI already hid the AirPlay tab pending streaming
support that was never going to ship.

Delete:
- crates/qbz-cast/src/airplay/        (entire directory)
- src-tauri/src/cast/airplay/         (entire directory)
- 10x v2_airplay_* commands from commands_v2/library.rs + their
  tauri::generate_handler entries
- AirPlayError from qbz-cast errors
- 'airplay' from CastProtocol + every case arm in castStore.ts,
  CastPicker.svelte, AudioOutputBadges.svelte
- Corresponding i18n keys if present

Zero behavior change for Chromecast / DLNA / QConnect / MPRIS. If
AirPlay ever becomes feasible we'll revisit from scratch with a
real streaming backend.

cc
2026-04-16 19:04:56 -06:00
vicrodh 27ab2c8bea refactor(search): decompose v2_search_all (Sonar S3776 — complexity 44 -> <15)
Split the monolithic v2_search_all command (44 cognitive complexity,
critical on Sonar) into four small single-purpose helpers:

- parse_results_page<T>(response, key, limit) — generic DeserializeOwned
  accessor that replaces four copy-pasted .get().and_then().unwrap_or
  blocks returning an empty SearchResultsPage.
- filter_page<T, F>(page, keep) — closure-based retain-with-total-
  adjustment that replaces three near-identical post-filter blocks.
- most_popular_from_item(item, blacklist) — per-item match over the
  three supported types; each arm is a flat parse + single blacklist
  check, no nested continues.
- pick_most_popular(response, blacklist) — traverses the items array
  via find_map, replacing the deeply-nested and_then/for/match/return
  stack that tripped the cognitive-complexity threshold.

Semantics are preserved: same filter criteria (including the track
"no performer" edge case), same skip-on-blacklist behaviour, same
return shape. v2_search_all itself is now ~30 lines, each helper is
<15 complexity.

cc
2026-04-16 18:39:57 -06:00
vicrodh ab31f6de97 fix(tray): use final matte-vinyl logo with blue rim for tray icon
The v1.2.6 regenerated tray glyphs were monochrome outlines of the Q:
the selection logic (prefer_dark_tray() -> dark-variant) picks
tray-dark.png on dark panels, which rendered as a black Q on dark
bg (issue visible in Plasma on emerge qbz-bin install).

Switch to a full-colour render of the final icon.svg (matte-vinyl
disc + blue rim + inner highlight). Full-colour rim gives definition
on both light and dark panels, so one PNG works for every variant:
the three tray files (tray.png, tray-light.png, tray-dark.png) now
point to the same render and the selection logic becomes a no-op.

cc
2026-04-16 12:34:24 -06:00
vicrodh 5dc2a26e1c fix(security): demote infinite-radio session_id log to debug level
CodeQL rust/cleartext-logging (alert #69) flagged the info-level log
that prints the radio session id on creation. The value is only
useful for local troubleshooting; demote to debug! so it stays out
of default-level logs (same treatment as the qbzd user_id logs).

cc
2026-04-16 12:12:47 -06:00
vicrodh 7b2b58a5d0 chore(deps): bump rand 0.9.2 -> 0.9.3 to fix GHSA-cq8v-f236-94qc (0.9.x line)
The 0.10.x advisory was fixed in v1.2.7 by bumping 0.10.0 -> 0.10.1.
The same advisory also applies to 0.9.x with a separate patched
version (0.9.3). Applying the bump to both lockfiles so the alerts
for the transitive 0.9.x deps close too.

cc
2026-04-16 12:08:14 -06:00
vicrodh 2a870e619a chore(release): bump version to 1.2.7 + release notes
Bumped:
- src-tauri/Cargo.toml + Cargo.lock
- src-tauri/tauri.conf.json
- package.json + package-lock.json
- snapcraft.yaml
- flake.nix (qbzVersion, hashes cleared for nix users to regenerate)

Added:
- packaging/flatpak/com.blitzfc.qbz.metainfo.xml (1.2.7 entry)
- docs/release-1.2.7/TAG_DETAILS.md (release notes — 1.2.7 preamble
  on top of the full 1.2.6 + 1.2.5 content)

cc
2026-04-16 12:03:18 -06:00
vicrodh d5e3fff45c feat(diagnostics): add OS / distro / install method / deps section
New v2_get_system_info command and matching UI section in the
System Diagnostics panel. What it reports:

- os (linux/macos/windows)
- arch (x86_64, aarch64)
- kernel_version from /proc/sys/kernel/osrelease
- Linux distro: id, version_id, pretty_name from /etc/os-release
  (falls back to /run/host/os-release so it works inside Flatpak)
- install_method: flatpak / snap / appimage / dev / native, detected
  via FLATPAK_ID + /.flatpak-info, SNAP, APPIMAGE env vars, and
  debug_assertions as the last fallback
- flatpak_runtime + version: parsed from /.flatpak-info
- loaded library versions (from /proc/self/maps, symlink-resolved):
  webkit2gtk-4.1, gtk-3 (or 4), glibc, alsa, pipewire, pulseaudio

All info is derived from local files/env; no sensitive data, no IDs,
no hashes. Included in the clipboard export.

cc
2026-04-16 11:14:26 -06:00
vicrodh fa7758f5bb fix(window,login): grant set-title capability + shrink login logo 20%
- Add core:window:allow-set-title to capabilities/default.json. Tauri 2
  rejects setTitle() from the frontend unless the capability is
  explicitly granted; without this the window-title feature silently
  fell back to "QBZ" in every case.
- Login logo: 175px -> 140px (-20%) per feedback.
- Drop the debug console logging on the title effect now that the
  wiring is confirmed working; keep the warn-on-failure.

cc
2026-04-16 09:48:43 -06:00
vicrodh 570532ce90 fix(branding): refresh tray icons + snap icon to match new vinyl Q
Tray icons (tray.png, tray-light.png, tray-dark.png) and the Snap
launcher icon were left at the v1.2.4 hand-drawn outline Q during the
v1.2.5 branding refresh. Regenerate them from the canonical
icon-symbolic.svg / 256x256.png so the system tray, KSNI panel and
Snap launcher show the matte-vinyl Q.

Also drop the drop-shadow filter on the login screen logo: with the
new icon's edge rim and inner highlight, the extra ambient shadow is
redundant and visually muddies the artwork.

cc
2026-04-15 21:49:25 -06:00
vicrodh f6377d5df9 chore(release): bump version to 1.2.6 + release notes
Bumped:
- src-tauri/Cargo.toml + Cargo.lock
- src-tauri/tauri.conf.json
- package.json + package-lock.json
- snapcraft.yaml
- flake.nix (qbzVersion, hashes cleared for nix users to regenerate)

Added:
- packaging/flatpak/com.blitzfc.qbz.metainfo.xml (1.2.6 entry)
- docs/release-1.2.6/TAG_DETAILS.md (release notes)

cc
2026-04-15 21:36:11 -06:00
vicrodh 33d65681c4 fix(queue): restore infinite play (V2 reimplementation)
Issue #319: Infinite Play toggle had no backend wiring after the V2
migration — toggle only wrote to localStorage and the queue-end handler
ignored it, so playback stopped at the last track.

Reimplemented in V2 (no legacy wrappers):

- New `v2_create_infinite_radio` command that takes the last few played
  track IDs, uses the most recent track's artist as the radio seed via
  `RadioPoolBuilder::create_artist_radio` (Qobuz `/radio/artist`
  endpoint), and returns 50 V2QueueTrack ready to append. Does NOT
  mutate the queue or playback context — caller decides how to use them.

- `playbackPreferencesStore`: expose `'infinite'` in `AutoplayMode`
  type and add `isInfinitePlayEnabled()` helper.

- `+page.svelte`:
  - `handleToggleInfinitePlay` now syncs to backend via
    `setAutoplayMode('infinite' | 'continue')` so the choice persists
    in `playback_preferences.db` across restarts (was localStorage only).
  - On startup load infinite play from cached preferences (was
    localStorage).
  - When `nextTrack()` returns null AND infinite play is on: collect
    the current track + last 5 history tracks, call
    `v2_create_infinite_radio`, append the result via
    `v2_bulk_add_to_queue`, then advance — playback continues without
    interruption.

i18n: add `toast.failedToSavePreference` to all 5 locales.

cc
2026-04-15 21:20:04 -06:00
vicrodh 1fe7d44311 fix(branding): resize icon to fill canvas + add blue edge rim
Tighten the viewBox so the vinyl fills ~97% of the canvas (matching
Steam/Chrome/Papirus sizing). Add a #3968be stroke rim around the
vinyl edge so the icon remains visible on pure-black backgrounds.
Include a subtle radial inner-light from top-left for depth.

Also adds 512x512.png for Flathub compatibility.

Regenerate all PNG exports: Tauri standard sizes, Windows Store
squares, ICO, Android mipmaps, and static logos.

cc
2026-04-15 20:22:00 -06:00
vicrodh a867d77528 fix(build): gate macOS-unreachable imports to cfg(target_os = "linux")
The v1.2.5 tag push flagged three macOS-only compile errors on the
release-macos workflow (Linux, aarch64 Linux, flatpak still building
successfully). All three are Linux-only symbols pulled unconditionally
into files that are compiled on every target:

  * src-tauri/src/tray.rs: imports PathBuf inside the
    `cfg(not(target_os = "linux"))` branch without `use std::path::PathBuf`.
    Add the import under the matching cfg so macOS / Windows see it.

  * src-tauri/src/commands_v2/playback.rs: `cached_audio_incompatible_with_hw`
    is defined only on Linux (ALSA). Callsites are already
    `cfg(target_os = "linux")`, but the shared `use super::{...}` block
    was not. Move that one symbol out into a Linux-only import.

  * src-tauri/src/commands_v2/legacy_compat.rs: same story with
    `v2_prepare_notification_icon_bytes` — only the Linux notification
    path calls it, but the import was unconditional.

No runtime behaviour change; this only unblocks the release-macos
(and any future Apple-target) workflow so it can build.

cc
2026-04-15 15:46:06 -06:00
vicrodh 6049240640 chore(release): bump version to 1.2.5 "Accolade Watch"
* src-tauri/Cargo.toml and Cargo.lock: 1.2.4 -> 1.2.5
  * src-tauri/tauri.conf.json: 1.2.4 -> 1.2.5
  * package.json (+ package-lock.json regenerated): 1.2.4 -> 1.2.5
  * snapcraft.yaml: 1.2.4 -> 1.2.5
  * packaging/flatpak/com.blitzfc.qbz.metainfo.xml: new <release> entry
    dated 2026-04-15 covering the visual refresh (new logo + login
    branding), Plasma/Klassy auto-detect for window controls,
    opt-in match-system-chrome rounded corners, Editor's Picks
    ribbon cleanup, the two sidebar menu positioning fixes shipped
    alongside this bump, and the DAC wizard contrast fix.

Tag not applied per request — release notes are staged, ready for
the screenshots pass and manual tag afterward.

cc
2026-04-15 13:16:36 -06:00
vicrodh 57e63e3ef5 feat(appearance): phase 2 of match-system-window-chrome (transparent)
Rounded-corner window chrome now actually works. The toggle in
Settings > Appearance persists to the window_settings SQLite store and
gates the Tauri window transparency decision at startup, so on next
launch the main window is rebuilt with `transparent: true` and the
border-radius plus edge outline apply without painting white corners.

Flow:
  * Toggle in Settings > Appearance -> windowChromeStore saves to
    localStorage and calls v2_set_match_system_window_chrome which
    writes match_system_window_chrome to window_settings.db.
  * Toast reminds the user to restart.
  * On next launch, should_use_main_window_transparency() reads the
    setting and returns true, Tauri builds the window transparent.
  * Setup stores the decision in MAIN_WINDOW_TRANSPARENT. A new command
    v2_main_window_is_transparent reports it back to the frontend.
  * +page.svelte only applies .app.match-chrome when the window is
    actually transparent AND the custom titlebar is visible, so
    stale-state toggling never produces white wedges.
  * CSS sets html/body transparent via a global class on <html>, then
    .app.match-chrome.floating paints the rounded corners plus a
    12/28 rgba drop shadow and a 1px darkening outline.
  * Maximized state clears the radius automatically.

Env vars QBZ_FORCE_TRANSPARENT_WINDOWS / QBZ_FORCE_OPAQUE_WINDOWS still
take precedence over the DB-persisted toggle.

cc
2026-04-15 11:31:11 -06:00
vicrodh 8502c2fdb3 feat(appearance): match system window chrome (phase 1) + ribbon cleanup
Adds an opt-in "Match system window chrome" toggle in Settings >
Appearance. When enabled (and the custom title bar is active, i.e.
neither "Use system title bar" nor "Hide title bar"), QBZ rounds its
own corners to match the detected desktop's decoration radius and
paints a thin edge outline so the window reads as its own surface
against the desktop instead of a bare rectangle.

Phase 1 scope:
  * New windowChromeStore (localStorage) holding the toggle and the
    detected corner radius.
  * desktop_theme module gains a best-effort window_corner_radius_px:
    10 for Klassy, 6 for Breeze Plasma, 12 for GNOME/Adwaita, 8 fallback.
  * +page.svelte subscribes to the detector; .app.match-chrome.floating
    applies the radius via a CSS variable plus a 1px darkening outline
    and a deeper shadow. Maximized state removes the radius.
  * SettingsView: new toggle, disabled when hideTitleBar or
    useSystemTitleBar is on; i18n for en/es.

External shadow that matches each DE's compositor remains a phase 2
item — it requires `transparent: true` on the Tauri window, which has
compositor stability trade-offs and must be restart-gated.

Also cleans up redundant AlbumCard ribbons in the Editor's Picks
TAB (distinct from the home tab that was fixed earlier): removes
ribbon={album.ribbon} from the `editorPicks` and `qobuzissimes`
sections inside the editor-picks tab block. Press Accolades and
Popular Albums keep their ribbons (they vary per card).

cc
2026-04-15 11:17:05 -06:00
vicrodh 6a5cfc7235 feat(titlebar): auto-detect KDE Plasma/Klassy theme for window controls
Adds a runtime-detected "Klassy (auto-detect)" preset for the custom
window controls that reads the user's Plasma decoration theme and
applies matching colors and shape. When Klassy itself is not installed
but Plasma is, the option re-labels to "Plasma (auto-detect)" and
still applies the desktop color scheme. Hidden on non-KDE / non-Linux.

Backend:
  * New desktop_theme module parses kwinrc, kdeglobals, and klassyrc
    without external deps. Exposes detect_desktop_theme Tauri command
    returning DesktopThemeInfo: desktop id, isKlassy flag, WM active/
    inactive background + foreground, accent, DecorationHover, and the
    user's Klassy ButtonIconStyle / ButtonShape when present.

Frontend:
  * windowControlsStore gains applyKlassyPreset(), a cached detection
    helper, and mapKlassyShapeToQbz() that maps Klassy's button shapes
    (FullHeightRectangle, FullHeightRoundedRectangle, Tab, Circle,
    Square, IntegratedRoundedRectangle, ...) onto QBZ's shape set.
  * Adds the full-height-rounded shape to ButtonShape to cover the
    most common Klassy style that QBZ was missing; CSS matches Klassy
    FullHeightRoundedRectangle visual (full-height tab with ~7px
    radius and margin).
  * SettingsView fires detection on mount, derives the preset list
    reactively so the auto-detect option only appears when relevant,
    and adds the new shape to the Style picker.
  * i18n keys added for the new shape and both preset labels in en/es.

cc
2026-04-15 09:43:00 -06:00
vicrodh 1cb8d2c305 feat(branding): refresh app icon, tray theming, and login branding
Apply the new matte-vinyl QBZ logo (Flathub-compliant: square canvas,
74% footprint, no baked shadows/gloss) across every icon asset: Tauri
standard sizes, Windows Store squares, Android icons and mipmaps,
static/, README, and the tray.

Ship two tray variants (black and white glyphs) and pick the right
one at runtime by reading GNOME color-scheme, GTK settings, and KDE
kdeglobals. Defaults to light-theme glyph when nothing matches.

Login screen: logo 80 -> 175 px with a subtle var(--text-muted)
drop-shadow using color-mix() for alpha, plus QBZ and "Qobuz(TM)
Player" under the icon. Update app.tagline i18n accordingly.

cc
2026-04-15 03:55:28 -06:00
vicrodh c63c3ab160 chore(audio-watch): drop deprecated DeviceTrait::name fallback
rodio deprecated `DeviceTrait::name()` in favour of `description()`
(name) / `id()` (stable identifier). The legacy fallback in
`preferred_name()` was unreachable on our target platforms anyway —
the only path that matters is the `description()` one, since that is
what `get_audio_devices()` persists in settings.

Skip the device if `description()` fails instead of falling back to
the deprecated API. Silences the build warning.

cc
2026-04-14 22:59:22 -06:00
vicrodh 8871262377 chore(deps): bump rand to silence dependabot advisories
- src-tauri: rand 0.10.0 → 0.10.1 via cargo update (alert #36)
- qbz-player: rand 0.8 → 0.9, `gen_range` → `random_range` (alert #38)

The underlying RUSTSEC issue only triggers for code that calls
`rand::rng()` alongside a custom logger — we don't do that anywhere,
so actual impact is nil. Bumping closes the alerts.

cc
2026-04-14 22:47:42 -06:00
vicrodh f7e51b980e fix(audio-watch): stop pre-flight device toast — stored name format never matched cpal enum
Level A of issue #307 was firing 'device no longer available' spuriously
because settings.output_device stores the ALSA-normalized form
(front:CARD=X,DEV=0 per normalize_device_id_to_stable) while cpal's
output_devices() enumeration returns different strings entirely —
DeviceTrait::description() (friendly) and DeviceTrait::name() both
produce values that don't match what the UI persisted. Comparing them
marked every configured device as 'missing' on normal playback.

Removes the pre-flight emit_missing_if_needed() calls from
v2_play_track and v2_resume_playback so the false-positive toast
stops firing. The init_device fallback still routes audio to default
on a truly missing device — we just no longer surface that to the UI,
which is net neutral for the user vs. wrong notifications.

Keeps audio_device_watch module, DeviceMissingThrottle state and the
frontend listener in place so Level B (emit from inside the audio
thread when the fallback actually triggers) can reuse the plumbing
without another refactor. Hardens the presence check with
empty-enumeration = inconclusive and description()-based matching for
whenever Level B actually wires up a call site that has a comparable
device name at hand.

cc
2026-04-14 21:12:03 -06:00
vicrodh b62a4b01f5 feat(audio): notify user when selected output device goes missing (issue #307)
Level A of issue #307. When the configured output device has
disappeared from the host device list at the time of play/resume
(KVM switched away, USB sink unplugged, PipeWire sink removed…)
the backend emits an 'audio:device-missing' event and the frontend
shows a warning toast:

  'Output device "XYZ" is no longer available. Using system default.'

The existing init_device fallback already routes audio to the system
default, so this is purely informational — the user now understands
why audio came out of a different sink instead of staring at a silent
progress bar.

Does NOT touch the sacred audio init path (pipewire_backend.rs,
init_device, audio_settings.rs). New module audio_device_watch.rs
adds a pure presence check, a new-state throttle so we don't emit on
every resume once the user's been notified, and a v2 command
v2_check_audio_device_presence so the frontend can poll on demand.

Ready for Level B (background watchdog) — the same helpers plug into
a timer without further refactor; Level B only needs to add a tokio
task that calls emit_missing_if_needed() every N seconds while
playback is active.

cc
2026-04-14 17:58:59 -06:00
vicrodh c4c4bdf244 fix(album-awards): accept LegacyAwardDto's awardId/awardedAt field names
Root cause of 'Discoteca Ideal Qobuz not clickable': /album/get
returns awards as LegacyAwardDto (per a20/c.java + LegacyAlbumDto
in the Android decompilation) whose id field is named awardId, not
id. Our AlbumAward struct only looked for 'id', so every award from
/album/get deserialized with id=None and the click fell back to the
catalog resolver — which was itself incomplete.

Adds serde aliases 'awardId' / 'award_id' for the id field and
'awardedAt' for awarded_at. Applies to both qbz-models (V2) and the
src-tauri legacy mirror.

Endpoint coverage:
- /discover/index  → {id, name, awarded_at}       (primary match)
- /album/get       → {awardId, name, awardedAt, …} (alias match)
- /artist/page     → {id, name, awarded_at}       (primary match)

cc
2026-04-14 16:02:08 -06:00
vicrodh 854eaaa0a4 feat(award): resolve award id by name via /award/explore when /album/get omits it
/album/get's AwardDto has id: String? — Qobuz sometimes returns an
award entry with only a name and no id. Rather than disabling the
click or toasting, we look up the id in the Qobuz award catalog.

- Adds v2_get_award_explore (paginated /award/explore) all the way
  through qbz-qobuz → core → bridge → command → invoke_handler.
- New frontend awardCatalogStore caches the full (id, name) list
  lazily, one fetch per session, resolved on first click that needs
  it. Further clicks are instant.
- handleAwardClick goes straight to navigation when id is present,
  falls back to resolveAwardIdByName otherwise. Toast only fires if
  the name isn't in the catalog either (genuinely unknown award).

cc
2026-04-14 15:06:15 -06:00
vicrodh 037e18423a fix(award): normalize award.id as string end-to-end so /award/* calls stop sending 'null'
The log showed 'award=null' reaching /award/getAlbums and Qobuz
replying '400 Bad Request: Invalid argument: award_id (accepted type
are number)'. Root cause: AlbumAward.id was typed Option<u64> and
/album/get returns award ids as strings; serde dropped them to None,
frontend stringified null → 'null', we sent that as the query param.

Fix:
- AlbumAward.id becomes Option<String> with a custom deserializer
  that accepts both string and integer JSON values.
- TS types + AlbumDetailView Award interface + pickAlbumRibbon all
  use string id (compared as '88'/'151' now).
- Sidebar button is disabled when id is falsy; handleAwardClick
  early-returns on empty id so we never navigate to award 'null'.
- Better body/status logging in get_award_albums stays so future
  4xx responses are visible in the log.

Also switches /award/page and /award/getAlbums to signed_get_auth
(app_id + user token). Mobile does the same — catalog visibility is
region-scoped and the unsigned variant returns 400.

cc
2026-04-14 14:39:20 -06:00
vicrodh a273174716 feat(award): use /award/getAlbums for the grid + follow award as entity
Two behavior fixes for AwardView based on user feedback.

1. Grid content. The previous implementation rendered the releases
   array from /award/page, which is user-scoped (only surfaces awards
   on albums the user already favorited), so the grid came up empty
   in almost every case. Switch to /award/getAlbums which is the
   public catalog listing with pagination. /award/page is still
   called, but only to hydrate the hero (name, image, magazine).

2. Follow-the-award. Awards themselves are followable on Qobuz mobile
   (it's what feeds the 'Awards' tab of Release Watch). Add:
   - favorite_awards table + store methods in favorites_cache.rs
   - 4 V2 commands (get/sync/cache/uncache) mirroring the label ones
   - awardFavoritesStore.ts — clone of labelFavoritesStore with string
     ids (Qobuz mobile API passes award_ids as strings)
   - Follow/Unfollow heart button in AwardView hero, matching the
     ArtistDetailView / LabelView visual
   - loadAwardFavorites() wired into the login bootstrap alongside
     loadArtistFavorites / loadLabelFavorites
   - i18n keys (award.follow / award.unfollow)

Also adds a Ver más button for paginated loading of the album grid.

cc
2026-04-14 13:02:37 -06:00