Commit Graph
2411 Commits
Author SHA1 Message Date
vicrodh d98cc569b0 test(visualizer): wrap SamplesBuffer args in NonZero for rodio 0.22
rodio 0.22 tightened SamplesBuffer::new to require NonZero<u16> for
channels and NonZero<u32> for sample_rate, but the visualizer test
fixtures still passed bare integer literals — so the qbz-audio test
binary stopped building. Wrap both call sites in NonZero::new(...)
.unwrap() so cargo test -p qbz-audio compiles again.

Pure test-only change; runtime visualizer code was already migrated.

cc
2026-04-24 19:46:05 -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 e8128b813a feat(core): add MemoryPressure snapshot for runtime watchdog
Extend qbz-core's system_capabilities with a parse_meminfo_available_kb
helper plus a small MemoryPressure value object that classifies the
host's current available memory against thresholds derived from
MemoryProfile. The watchdog (src-tauri side, next commit) polls this
to decide when to evict caches.

Thresholds intentionally simple for v1: is_low triggers under 15 %
available, is_critical under 5 %. Both are strict-inequality so the
exact-percent boundaries don't flap.

The pure pressure_from_figures(avail_kb, total_kb) factors out the
math from the /proc read so threshold behavior is tested without
touching the filesystem. Refactors the existing
parse_meminfo_total_kb to share a parse_meminfo_field_kb helper with
the new available-memory parser.

Adds 9 tests covering the parser, the threshold boundaries, the zero-
total divide-safety guard, and the codehd7 issue #331 scenario
(Pi 3B, ~25 MB available out of 938 MB → critical).

cc
2026-04-24 19:41:12 -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 0a4b4095f5 feat(core): add system_capabilities::MemoryProfile auto-detect
New module in qbz-core that probes /proc/meminfo at startup and
classifies the host into Normal (>= 2 GB RAM) or LowMemory (< 2 GB)
buckets, then derives runtime caps for memory-sensitive subsystems:
prefetch_count, max_initial_buffer_bytes, max_concurrent_prefetch,
allow_hires_prefetch.

Detection is split into a pure parse_meminfo_total_kb +
detect_profile_from_meminfo pair (testable with synthetic input)
and a thin detect_profile wrapper that reads the file. The
process-wide profile is cached in a OnceLock and logged once at
first access, so subsequent callers pay zero filesystem cost.

The Normal-fallback path covers macOS, Windows, and any Linux where
/proc/meminfo is missing or unparseable — we never accidentally
throttle a system whose memory we couldn't measure.

This commit only ships the module; wiring into the prefetch loop
and streaming buffer follows in the next commits.

Adds 10 unit tests covering the parser, the threshold boundary
(exactly 2 GB → Normal, just under → LowMemory), the Pi 3B and
Pi Zero 2W cases that motivated this work (#331), and the
unparseable-input fallback.

cc
2026-04-24 19:22:08 -06:00
vicrodh 18d8bc3572 fix(alsa): gate hw fallback on card presence, not stream0 rates (#331)
The hw:CARD=…,DEV=… fallback added in 1.2.8 (commit 927fabbd7) only
fired when the helper chain
  extract_card_name_from_device(device_id)
      .and_then(|card| get_hw_supported_rates(&card))
      .is_some()
returned Some — but get_hw_supported_rates parses
/proc/asound/cardN/stream0, which is only emitted by the USB-audio
driver. I2S, PCI and built-in cards (HifiBerry HATs, intel-hda, etc.)
don't have a stream0 file at all, so the gate evaluated to false for
the very devices that needed the fallback the most. The reporter's
HifiBerry Digi2 Pro on Raspberry Pi OS hit this exactly: 1.2.8's
ALSA Direct path logged "Looking for device: iec958:CARD=…", failed
the lookup, and dropped straight to the proc_found=false error
branch without ever attempting hw:CARD=sndrpihifiberry,DEV=0.

Extract a small is_card_present_in_proc helper that uses
find_card_number_by_name (which reads /proc/asound/cards — present
for every registered card regardless of bus) and use it for both
gate checks: the fallback trigger and the proc_found error message
selector. get_hw_supported_rates keeps its three legitimate uses
(actual rate validation in create_output_stream, and the two public
device_supports_sample_rate / get_device_supported_rates queries).

Adds 8 unit tests covering build_hw_fallback_id, the alias-prefix
branches of extract_card_name_from_device, the short-circuit cases
of is_card_present_in_proc, and the existing is_known_pcm_id filter.
The /proc-touching branches stay covered by hardware testing on the
reporter's Pi when v1.2.9-rc ships.

cc
2026-04-24 19:17:59 -06:00
vicrodh ec1bf38791 test(visualizer): wrap SamplesBuffer args in NonZero for rodio 0.22
rodio 0.22 tightened SamplesBuffer::new to require NonZero<u16> for
channels and NonZero<u32> for sample_rate, but the visualizer test
fixtures still passed bare integer literals — so the qbz-audio test
binary stopped building. Wrap both call sites in NonZero::new(...)
.unwrap() so cargo test -p qbz-audio compiles again.

Pure test-only change; runtime visualizer code was already migrated.

cc
2026-04-24 19:17:40 -06:00
vicrodh 7ac5c7e8a0 perf(audio): skip CPAL probe of virtual ALSA PCMs during enumeration
build_cpal_device_map iterated every PCM that cpal's ALSA host exposes
(dmix:, route:, surround51:, pulse, null, …) but only five name shapes
are ever looked up downstream: default, sysdefault:CARD=*, and
{front,hdmi,iec958}:CARD=*,DEV=*. Dropping the rest avoids holding
Device handles we never query and, combined with the new error
handler, removes another source of spurious libasound probes on
systems where PipeWire owns the hardware exclusively.

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

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

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

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

cc
2026-04-24 12:05:04 -06:00
vicrodh d871d924aa chore(deps): bump rustls-webpki from 0.103.12 to 0.103.13 in /crates
cc
2026-04-24 11:38:52 -06:00
vicrodh 1e13447921 feat(library): detect network-mounted tracks at index time
Adds an is_network_mount flag to local_tracks. Populated by reading
/proc/mounts (falling back to /run/host/proc/mounts for Flatpak
sandboxes) and matching the file path's longest-prefix mount against
a list of network filesystems — nfs/nfs4, cifs/smb/smb3, fuse.sshfs,
fuse.rclone, fuse.gvfs, davfs/webdav, 9p, ceph, glusterfs, afs, afp.

Motivation: the earlier offline-awareness commit used a frontend
heuristic that matched /mnt, /media, /run/media and Windows UNC. That
catches the classic cases but misses the common sandbox case — a user
on Flathub or Snap mounts their NAS via GVFS at /run/user/1000/gvfs/…
or bind-mounts it into /home/user/music, and neither of those
triggers the frontend check. The backend sees the mount type
directly, so flagging the row at index time gives the UI ground truth.

Changes:
  * new crates/qbz-library/src/mount_info.rs — is_network_path(&Path)
    helper with /proc/mounts parsing, longest-prefix mount matching,
    and a NETWORK_FS_PREFIXES list. Guarded with cfg(target_os =
    "linux"); stubs return false on macOS / Windows (frontend keeps
    its string-match heuristic there).
  * LocalTrack gains is_network_mount: bool (serialized camelCase).
  * local_tracks table: new is_network_mount INTEGER NOT NULL DEFAULT
    0 column, both in the initial CREATE TABLE and a migration for
    existing DBs.
  * insert_track calls mount_info::is_network_path(&track.file_path)
    and writes the flag. Qobuz cached-download inserts rely on the
    column default (always 0) since those land inside the app's own
    data dir.
  * TRACK_COLUMNS + row_to_track pick up the new column. The two
    inline LocalTrack builders in get_playlist_local_tracks and
    get_playlist_local_tracks_with_position are updated the same way,
    plus the playlist_position index shifts by one.

cc
2026-04-21 13:27:31 -06:00
vicrodh 13ecec3b17 feat(library): add playlist_plex_tracks table and CRUD for Plex-in-playlist
First half of the Plex-tracks-in-playlists fix. Adds a dedicated
playlist_plex_tracks table alongside the existing playlist_local_tracks
so Plex content (TEXT rating key, owned by a remote server) doesn't
collide with the i64 filesystem id used by local_tracks. No foreign
key to plex_cache_tracks — the cache can be purged independently and
the user's intent should survive.

Database methods added:
  * add_plex_track_to_playlist(playlist_id, rating_key, position)
  * remove_plex_track_from_playlist(playlist_id, rating_key)
  * get_playlist_plex_tracks_with_position(playlist_id) ->
    Vec<(rating_key, position)>
  * get_playlist_plex_track_count(playlist_id) -> u32

get_all_playlist_local_track_counts now merges plex counts into the
same HashMap so the sidebar's hasLocalContent signal stays a single
number and covers Plex-populated playlists without a second round
trip from the frontend.

cc
2026-04-21 11:27:51 -06:00
vicrodh dd443ba822 perf(player): use Symphonia native seek for cached-track seeks
Adds InMemorySource — a Symphonia FormatReader+Decoder backed by an
in-memory Cursor — and wires it into the cached branch of the Seek
handler so FLAC Hi-Res and MP3 seeks on LRU-cached / local-library /
offline-cached tracks jump via seek table instead of decoding every
sample from byte 0. Matches the streaming path's behavior, eliminates
the CPU spike during long seeks that was producing audible stuttering
after the streaming seek fix landed.

Falls back to the previous decode_with_fallback + skip_duration path
when Symphonia can't probe the format (rodio-only MP4/AAC container)
or when the native seek itself fails, so nothing regresses for those
formats.

Part of #335.

cc
2026-04-20 21:25:17 -06:00
vicrodh 11d01a2075 perf(player): use Symphonia native seek for CMAF streaming seeks
Replaces skip_duration's decode-every-sample-from-zero loop with
FormatReader::seek(SeekMode::Accurate, SeekTo::Time) on a fresh
IncrementalStreamingSource. For FLAC this jumps via the seek table to
the nearest seek point, then decodes forward to the exact sample —
instead of grinding through millions of samples under the buffered
source's mutex (the cause of post-seek stuttering).

Added IncrementalStreamingSource::seek_to(Duration) which also resets
the decoder, clears the sample queue, and rearms the finished flag so
the source is cleanly re-primed for the iterator.

Non-streaming seeks (cache, offline-cache, local library) still use
skip_duration — they hit in-memory bytes with no lock contention, so
the linear decode is effectively free and there's no reason to change
the path.

Part of #335.

cc
2026-04-20 21:15:14 -06:00
vicrodh f58ee31ed7 fix(player): allow seek within buffered zone during CMAF streaming
Relaxes the seek gate added in 2dc1ee9e so seeks that fall inside the
already-downloaded portion of a streaming track succeed instead of being
rejected wholesale. The cutoff is approximated from download fraction ×
track duration with a 10% safety margin (0.90) to cover FLAC/VBR byte
density variance; exact for CBR.

Seeks past the watermark still reject cleanly with a warning log — they
would block the audio thread since skip_duration reads samples
sequentially. Cache, offline-cache, and local-library seeks are
untouched (they don't hit the streaming branch).

Part of #335.

cc
2026-04-20 21:06:00 -06:00
vicrodh 2dc1ee9e9f fix(player): reject seek during active CMAF streaming
Tightens the streaming-seek handler to the simpler contract signed off
by the user: seek works after a streaming track finishes downloading
(buffered_source.is_complete()), but is rejected cleanly with a warning
log while the download is still in progress. Avoids skip_duration
blocking the audio thread on past-watermark seeks without adding
watermark-tracking complexity.

Cache, offline-cache, and local-library seeks are unaffected — they
reach this handler with current_audio_data Some and skip the streaming
branch entirely.

Fixes part of #335.

cc
2026-04-20 20:52:03 -06:00
vicrodh 7d9a847a87 fix(player): honour seek during active CMAF streaming
The audio thread's Seek handler bailed on `current_audio_data.is_none()`
with a warning and returned. That field is intentionally None during
streaming playback — the bytes live inside current_streaming_source's
Arc<BufferedMediaSource>. Effect: every seek on a streaming track with
the CMAF download still in progress was silently dropped; the UI sent
the command, the player ignored it, and the seekbar snapped back to
whatever position the playback timer had reached (issue #335).

Branch the handler on the live source kind. Streaming: build a fresh
IncrementalStreamingSource from the shared buffered_source (new reader
at byte 0, same downloaded bytes — no re-download), then skip_duration
to the target. Non-streaming path unchanged.

Within-watermark seeks now work. Past-watermark seeks currently block
the audio thread while the decoder waits for more bytes; the clamp to
prevent that lands in the next commit.

cc
2026-04-20 20:28:48 -06:00
vicrodh 927fabbd78 fix(alsa): fall back to hw:CARD=<name>,DEV=<n> when aliased id can't open
Minimal distros (the reporter's Raspberry Pi OS case in issue #331) ship
a trimmed /etc/asound.conf that defines only `!default` and doesn't pull
in the broader alias set most desktop distros provide. A card like the
HifiBerry Digi2 Pro — S/PDIF-only, so its selectable PCM is
`iec958:CARD=sndrpihifiberry,DEV=0` — then fails to open even though
the card is present and usable via the raw `hw:` device.

When CPAL's primary lookup by id returns None AND /proc/asound confirms
the card exists, rebuild the id as `hw:CARD=<name>,DEV=<n>` and retry
the lookup. The raw `hw:` PCM is exposed by the kernel driver for every
registered card, so it's a safe last resort — same card, same channel
routing, just skipping whichever userspace alias layer was missing.

Log both the alias failure and the fallback at warn/info so users can
see what happened without needing to diagnose ALSA config by hand.
Original error path is preserved when no fallback is possible (unknown
id shape, card not in /proc/asound, or hw: equally unavailable).

cc
2026-04-20 20:12:39 -06:00
vicrodh 05f0d69fcb fix(alsa): recognise iec958:/hdmi: prefixes in extract_card_name_from_device
Device enumeration produces four "CARD=" alias shapes (front:, sysdefault:,
iec958:, hdmi:) but the extractor only handled the first two. When a user
selected an S/PDIF or HDMI device id, the downstream proc_found check
always returned false, so the device-not-found error message dropped to
the generic "disconnected, renamed, or handled by another app" wording
even when /proc/asound showed the card present.

Part of issue #331 — reporter's HifiBerry Digi2 Pro is S/PDIF-only, so
its id is iec958:CARD=sndrpihifiberry,DEV=0, and the missing prefix match
obscured the real cause of the open failure.

This commit is a pure parsing extension; behavior changes only in the
error message accuracy. The hw:/plughw: fallback attempt lands in the
next commit.

cc
2026-04-20 20:09:27 -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 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 cf74dae4c5 feat(mixtape): qbz-models types for collections and items
Polymorphic item_type (album | track | playlist) via enum.
Two-value AlbumSource (Qobuz | Local) — LocalLibrary resolves
providers internally. Serde snake_case for JSON interop.

cc
2026-04-18 17:48:20 -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 82781d3e55 perf(player): bump gapless lead time from 5s to 10s
Offline-cache v2 bundles need runway for the decrypt pipeline. At 10
MB/s (software AES fallback) a 58 MB track needs 6 seconds, which
blew past the old 5s pre-emptive window and missed the gapless handoff
entirely. With AES-NI active the decrypt is ~500ms, so 10s is
harmless overkill — the decrypted bytes just sit in L1 a few seconds
earlier.

Design note: this is complementary to the AES-NI target-feature fix,
not a substitute. A too-small lead time makes gapless brittle even on
fast hardware (any hiccup blows the window); a too-large one is
wasted memory but otherwise fine. 10s is comfortable for every
realistic hardware/track-size combo we'll see.

If we ever want user-visible control, plumb a `gapless_lead_secs`
through AudioSettings and read it at the hot site.

cc
2026-04-17 13:12:08 -06:00
vicrodh aae685e701 perf(cmaf): enable AES-NI + zero-alloc decrypt loop
CMAF decrypt was the bottleneck for offline-cache gapless. Live
testing showed a 208 MB HiRes bundle took ~22 seconds to decrypt end-
to-end, which made gapless for anything longer than a 3-minute track
impossible (the gapless pre-emptive window is ~5s before track end,
so a 22s decrypt can't land in time regardless of how early we start).

Two changes, complementary:

1. `.cargo/config.toml` with `target-cpu=x86-64-v3` (Haswell baseline).
   The `aes` crate ships both a software implementation and an AES-NI
   hardware path, runtime-dispatched via `cpufeatures` — but the
   AES-NI path is only compiled in when the appropriate target-feature
   is enabled at build time. Default dev builds compile ONLY the
   software path, which hits ~10 MB/s on a modern CPU. Haswell+ (2013
   and up, every realistic QBZ user) supports AES-NI and clears
   400 MB/s+. For prebuilt distribution binaries we'll want runtime-
   dispatch via subfunction build or dynamic linking, but for self-
   built and release-source-from-tag scenarios this is the right
   default.

2. `decrypt_segments_into` no longer allocates a Vec per frame. Was:
     frame = seg_data[..].to_vec();  // alloc
     decrypt_frame(&mut frame);      // in-place
     output.extend_from_slice(&frame); // copy back
   Now:
     let start = output.len();
     output.extend_from_slice(&seg_data[..]);  // single copy
     decrypt_frame(&mut output[start..]);      // in-place
   For a HiRes FLAC with ~50K frames this saves 50K small allocs +
   50K double-copies. Output Vec is already pre-sized via
   with_capacity(total), so no reallocation growth during extend.

Combined expected impact: 22s decrypt on a 208 MB track → under 500ms,
which fits inside the 5s gapless window with headroom to spare.

cc
2026-04-17 12:45:42 -06:00
vicrodh 91a48e458c fix(cmaf): serialize ensure_cmaf_session to kill concurrent-start race
The CMAF session cache is an Arc<RwLock<Option<CmafSession>>>. The old
ensure_cmaf_session did a read-check then dropped the guard then did
its POST unlocked, which meant two concurrent callers would each see
"no session", each POST /session/start, and each receive a different
session_id + infos. The second to finish stored its session as the new
cache, clobbering the first.

That was a problem because file/url wraps the per-track AES key against
whichever session_id the X-Session-Id header carried. If task A got its
wrapped key under session X but the cache got overwritten to Y before
decrypt time, the HKDF over Y-infos produced the wrong session_key and
the AES-CBC unwrap failed with "Unpad Error" — which is exactly the
warning that showed up in prefetch runs during parallel downloads and
caused the >3s gap between offline-cached tracks when the next track's
prefetch had silently fallen back to legacy.

The symptom in logs:
  [CMAF] Starting new session        ← task A
  [CMAF] Starting new session        ← task B (same ms)
  Session started: id=bHoSYBFx...
  Session started: id=bJ-w2wgm...    ← cache now holds B
  [PREFETCH] CMAF failed for track N: Content key unwrap failed:
    AES-CBC unwrap failed: Unpad Error

Fix: double-checked lock pattern. Fast path stays on the read guard
for existing-valid sessions. Slow path takes the write guard, re-checks
under exclusive ownership, and only the first caller actually hits
session/start. Everyone else wakes up and returns the freshly-cached
value.

Compile check only — full behaviour validation in the running dev
session after this commit.

cc
2026-04-17 09:52:39 -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 1743c9e5ec feat(secrets): add qbz-secrets crate for device-bound secret wrapping
Foundation for the offline-cache hardening story. The offline cache
needs to persist per-track AES content keys to disk, but storing them
plaintext would make copying the SQLite DB enough to decrypt the
cached audio on another machine.

qbz-secrets provides a uniform wrap/unwrap API backed by:

1. **OS keyring** (preferred): libsecret/gnome-keyring on Linux,
   Keychain on macOS, DPAPI on Windows. A 256-bit master AES key
   lives inside the OS secure store; all wraps go through it. This
   is the gold-standard device binding used by commercial music
   apps (Spotify, Apple Music, Tidal) for the same class of data.

2. **HKDF fallback**: when the OS keyring is not reachable (headless
   setups — Raspberry Pi, Docker, the future qbzd daemon on a
   server), the master key is derived on demand via HKDF-SHA256
   over service_name + machine_id + a persistent per-install UUID.
   Weaker than keyring (reversible by anyone with filesystem access
   to both sources) but still device-bound against cross-machine
   copy attacks, and lets daemon/headless variants work at all.

Every wrap uses AES-256-GCM with a fresh random 12-byte nonce. The
on-disk envelope carries a version byte + backend marker + nonce +
ciphertext+tag, so (a) we detect tampering via GCM, (b) we refuse to
silently unwrap a blob produced by a backend we can't reach, and (c)
we have a forward-compat path for future key rotation.

4 unit tests cover: small-secret round-trip, 16-byte content-key
round-trip (the exact CMAF use case), GCM tamper detection, and
nonce-randomness (two wraps of the same input differ).

This crate intentionally has zero QBZ dependencies — it's a generic
primitive. Next commits wire it into the offline cache schema + the
DB migration.

cc
2026-04-17 00:53:01 -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 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 c3a270fd18 chore(deps): bump rand 0.10.0 -> 0.10.1 to fix GHSA-cq8v-f236-94qc
Advisory: rand is unsound with a custom logger using rand::rng().
Affects 0.10.x only; 0.10.1 fixes the issue. The older rand versions
still present in the lockfiles (0.7.3, 0.8.5, 0.9.2) are transitive
dependencies that do not use the rand::rng() API surface introduced
in 0.10 and are not exploitable via this advisory.

The lru 0.12.5 advisory (GHSA-rhfx-m35p-ff5j) is a transitive dep of
ratatui 0.29.0 (used by qbzd). Can't be bumped without upstream
ratatui dropping the old lru; tracked separately.

cc
2026-04-16 10:16:38 -06:00
Victor RH 76db2015c1 Merge pull request #314 from vicrodh/dependabot/cargo/crates/rustls-webpki-0.103.12
chore(deps): bump rustls-webpki from 0.103.9 to 0.103.12 in /crates
2026-04-15 16:31:51 -06:00
vicrodh 0461c36912 security: address CodeQL alerts from the v1.2.5 scan
Triaged the 8 open CodeQL alerts raised on the 1.2.5 merge commit.

Critical (1) — real, fixed:

  * .github/workflows/release-updater-manifest.yml:29 — the resolve
    step interpolated `${{ github.event.workflow_run.head_branch }}`
    directly into a shell heredoc. `head_branch` on `workflow_run` is
    attacker-controllable (any outside contributor can push a branch
    whose name embeds a payload). Route the value through an env var
    (HEAD_BRANCH) and validate the shape with a strict regex before
    letting it flow into shell or $GITHUB_OUTPUT.

Warnings (2) — real, fixed:

  * .github/workflows/build-qbzd.yml had no `permissions:` block, so
    every job inherited the repo's default GITHUB_TOKEN scope. Add a
    top-level `permissions: contents: read`; the existing `release`
    job keeps its own `contents: write` override for publishing
    artifacts.

Warnings (3) — real, low severity, mitigated:

  * crates/qbzd/src/{daemon.rs,session.rs} logged `user_id` at info
    level (CodeQL rust/cleartext-logging). Demoted the three lines to
    `log::debug!` so default-level logs stay free of the numeric user
    id, with a comment noting RUST_LOG=debug re-enables them for
    diagnostics.

Warnings (2) — false positives, dismissed in GitHub:

  * crates/qbzd/src/login.rs:103 — `println!` to stdout inside the
    interactive `qbzd login` subcommand; login confirmation UX, not a
    persistent log. (alert #63 dismissed, reason: false positive)
  * crates/qbzd/src/qconnect.rs:540 — `session_uuid` is an ephemeral
    per-connection identifier QConnect exchanges in cleartext itself;
    not a credential or token. (alert #65 dismissed, reason: false
    positive)

cc
2026-04-15 16:10:06 -06:00
dependabot[bot] 56b7b24c90 chore(deps): bump rustls-webpki from 0.103.9 to 0.103.12 in /crates
Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.9 to 0.103.12.
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](https://github.com/rustls/webpki/compare/v/0.103.9...v/0.103.12)

---
updated-dependencies:
- dependency-name: rustls-webpki
  dependency-version: 0.103.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-15 21:52:32 +00: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 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 e52e3e1fdc fix(award): loosen AwardPageData and AwardMagazine for inconsistent Qobuz shapes
Matches Android's AwardDto where every field is nullable. Our struct
required id and name as String which made /award/page fail parsing
with 'missing field `id`' as soon as any award response deviated from
the happy path (e.g. a deprecated award, an id returned as integer,
or a reduced payload).

All top-level AwardPageData fields are now Option; id + awarded_at
accept string OR integer via a shared deserializer. AwardMagazine.id
gets the same treatment. Frontend TS type matches the looser shape.

cc
2026-04-14 12:47:33 -06:00
vicrodh ee2f413ec1 fix(album): use dedicated tolerant AlbumAward struct instead of mutating PageArtistAward
Previous attempt loosened PageArtistAward (id u64 -> Option<u64>,
awarded_at custom deserializer) to accept /album/get's inconsistent
award shape. That rippled into /artist/page consumers, which the user
rightly called out ('no eliminar lo que ya funcionaba, adaptar pues').

Restores PageArtistAward to its original shape (id: u64, name: String,
awarded_at: Option<String>) and introduces a parallel AlbumAward type
used only on DiscoverAlbum and Album. AlbumAward tolerates missing id,
accepts awarded_at as int or string, and never fails deserialization
of the parent payload. /artist/page keeps its strict contract.

cc
2026-04-14 12:30:18 -06:00
vicrodh fe3213ece5 feat(award): backend V2 commands for /award/page and /award/getAlbums
Backend plumbing for Phase 3 AwardView. New endpoints:
- v2_get_award_page(awardId)       → hero + categorized releases
- v2_get_award_albums(awardId, …)  → paginated album list

Mirrors Android's AwardDto shape (id, name, image, awarded_at,
magazine, releases, playlists). Follows the label/page pattern for
signing and error handling. Deserializer accepts the V2 generic
{has_more, items} envelope as well as the legacy search-style
{albums: {items, total}} in case Qobuz swaps shapes per account.

cc
2026-04-14 12:12:01 -06:00
vicrodh f0b4499dab fix(album): accept awarded_at as integer or string on PageArtistAward
/album/get returns award.awarded_at as a Unix timestamp integer while
/discover/index returns it as a YYYY-MM-DD string — our struct only
accepted String, so /album/get started failing with
'invalid type: integer `…`, expected a string' as soon as awards were
added to Album in Phase 2. Every 'Load album' call broke.

Adds a custom deserializer that accepts both shapes and normalizes to
String downstream. Applied in both qbz-models (V2) and src-tauri legacy
api::models for symmetry.

cc
2026-04-14 11:28:19 -06:00
vicrodh 7bf9972846 feat(album-view): right sidebar with label and full awards stack
Ports the Qobuz web 'label + awards' sidebar pattern to AlbumView.
Track list and sidebar sit side-by-side above 1100px and collapse
vertically below it. Label card navigates to the existing LabelView.
Award cards call onAwardClick when provided (disabled until Phase 3
lands AwardView).

Wiring:
- Adds awards (Vec<PageArtistAward>) to both qbz-models Album and
  src-tauri api::models Album so /album/get payloads hydrate the
  field instead of silently dropping it.
- Adds awards to QobuzAlbum and AlbumDetail frontend types plus the
  convertQobuzAlbum adapter.
- New album.sidebar i18n keys in all five locales.

Card ribbon still shows only the LAST award (Phase 1 behavior);
sidebar renders every award as a tap target — mobile-style.

cc
2026-04-14 11:11:24 -06:00