Commit Graph
56 Commits
Author SHA1 Message Date
vicrodh 953ba39e77 feat(playlists+queue): sort directions (#657), import refresh, Play later (#442)
- Sidebar + playlist manager sorts gain both directions with a caret on
  the active row (re-select flips); 'recent' keeps the API order
  (newest-first — the old reverse was the #657 bug).
- Import now inserts the new playlist optimistically and bounded-retries
  until the API lists it (read-after-write lag forced a manual refresh).
- Play later: tracks land at the end of the manual block (after every
  play-next/later, before the source resumes); Play next and Add to
  queue are untouched. The queue sidebar shows the section divider
  (Next in queue / Next up) with drag-math compensation, and the context
  menu shows the landing position of each queue action.

km
2026-07-25 12:05:27 -06:00
vicrodh 8c2dfd5727 feat(models): capture account country and language on login
parse_login_response now reads user.country_code and user.language_code
(snake_case wire names, verified against Qobuz's own embedded login
fixture) into two new serde-defaulted Option fields on UserSession, so
pre-v10 persisted sessions still load with both as None (translation
stays Auto-off). qbzd's test construction updated for the new fields.

km
2026-07-22 19:11:52 -06:00
vicrodh fbf95ee03f feat(models): shared QualityLimit cause enum, min_tier, STREAMINFO prober
Groundwork for the #638 quality fixes: ONE cause vocabulary shared by the
local badge, the local device cap (fix 3) and the cast surfaces (fixes 1/4)
so nobody grows a second enum; Quality::min_tier for cap clamping (the
derived Ord is tier-correct — discriminants 5<6<7<27 ascend with tier);
and the proven FLAC STREAMINFO bit math hoisted verbatim from
qbz::remote_stream into a shared prober that returns None instead of
guessing defaults (callers keep their own fallbacks).

cc
2026-07-20 14:33:02 -06:00
vicrodh 6aa314b8ef fix(discover): sub-genre filter sends raw ids to the server (Tauri parity)
/discover/index honors sub-genre ids server-side, so send the raw selection
(parent or sub-genre, exactly as toggled) instead of mapping to the top-level
ancestor and narrowing client-side by genre.path — the previous hybrid widened
sub-genre selections back to their parent and dropped albums tagged only at top
level. Removes the client-side narrowing and its now-dead helpers; keeps a
diagnostic log of the outgoing genre_ids.

cc
2026-07-16 16:30:12 -06:00
vicrodh b3d12e6b2c fix(playback): give the now-playing "source" button a per-track context
The "playing from" button read a single global context set once at play time;
refresh_now_playing_meta never republished it, so it went stale across track
changes (previous track's album) and single-track plays fell back to the
current track's album instead of the playlist it came from. Add per-track
context_kind/context_id to QueueTrack (serde-default None, back-compat), stamp
the real container at every play path (album/artist/playlist/label), and
republish the context from the current track on EVERY track change (gapless
included) — never cached. open-context routes all stamped kinds.

cc
2026-07-13 13:11:43 -06:00
vicrodh e73dcf65da fix(parse): lenient per-item parsing everywhere a bad item blanked a list
Extends the #556/#565 fix (one malformed item in a JSON array nuked
the whole list via from_value::<Vec<T>>().unwrap_or_default()) from
favorites to every remaining silent-blank site found by audit: the
search parse_page (EVERY search tab — scalars now parse separately,
items per-item), For You fav-albums/fav-artists rails, the daily-mix
favorite_tracks source, and qbz-qobuz get_dynamic_suggest. The helper
moves to qbz-models::lenient (the one crate all consumers reach) with
unit tests; favorites re-uses it via alias, local copy deleted. The
~30 endpoints whose parse errors PROPAGATE stay as-is — honest errors
stay honest. New parse_page test covers a poisoned item surviving.

cc
2026-07-08 11:00:15 -06:00
vicrodh 7a31988767 feat(player): show album release version in now-playing + MPRIS
The now-playing bar, MPRIS metadata, and the desktop notification showed
the clean album name; for a re-edition (e.g. Octavarium (2009 Remaster))
the variant was lost. Carry the album-level version on QueueTrack
(populated on the album-play path) and append it to the displayed album.
Last.fm scrobbling keeps the CLEAN album name so editions don't fragment
stats. New album_version field defaults to None on every other queue source.

cc
2026-06-30 14:07:33 -06:00
vicrodh 8e02fecd27 feat(album/search/artist): show release version in album titles
Albums with a distinct version (remaster, Hi-Res re-edition, deluxe, etc.)
rendered as identical duplicates because the version was dropped. The web
player appends it in parens; mirror that via format_album_title across the
discography (map_release), search (map_album), the album header (map_album),
and the shared card mapper (suggestions/label/favorites). Verified against
live API: some variants carry the suffix in title (version null), others in
the version field — appending only when present matches the web player with
no double suffix. Add the version field to the Album model.

cc
2026-06-30 13:14:07 -06:00
vicrodh 9e2a1d1266 fix(models): tolerate missing pagination fields in album page
/album/suggest returns a page with only {limit, items} (no total or
offset). SearchResultsPage required them, so the whole AlbumSuggestResponse
failed to deserialize and the album Suggestions carousel silently never
showed. Default the pagination scalars so only items is required.

cc
2026-06-30 13:13:31 -06:00
vicrodh 0ddcad40a6 fix(album): credit composer from album-level field, not per-track
The header credit line derived composers by scanning every track's
composer, which over-credited songwriters on non-classical albums
(e.g. Anthrax showed Spitz/Bello/Benante). The official web player
instead uses the single album-level composer field and suppresses the
Various Composers placeholder.

Mirror releaseArtistsMapper: append one album.composer, skipping it
when the name contains VARIOUS (case-insensitive) or the id is already
credited. Add the composer field to the Album model.

cc
2026-06-30 11:03:35 -06:00
vicrodh be487dca4c feat(slint): work grouping, ru/ja locales, About + What's New + Discover reco modal
- Album view: classical "work" section grouping (PR #536 port) — new
  Track.work model field + run-length per-work headers in AlbumView.
- i18n: add Russian + Japanese locales (1365 msgids each); extend plural.rs
  (ru 3-form one/few/many, ja single-form) + tests; fix the language-selector
  reseed (main.rs set_languages + ui_prefs index maps). Seven languages total.
- About modal: native port of the Tauri About (version, links, build info,
  one-paragraph acknowledgments, inline contributors with GitHub avatars,
  signature). Contributors credit hoyon (PR #536) and mxnix (PR #517, ru).
- What's New modal: renders the GitHub release-tag description (markdown
  sections + TOC); release fetch + build.rs emits build date/commit.
- Discover Recommendations: move Cache window + Refresh now into the Customize
  modal, gated to the reco tab; hide the section-config UI there; add an
  explainer paragraph. New strings translated to all six non-English locales.
- About modal width +120px; splash screen gains the legal disclaimer footer.

cc
2026-06-29 22:55:17 -06:00
vicrodh 6520c007ae fix(radio): repair /radio/* deserialization and wire every radio entry point
Qobuz /radio/album and /radio/track return a tracks page that omits
`total`; the strict SearchResultsPage<Track> derive rejected it with
"missing field `total`", breaking AlbumView and Discover For You radio.
Port Tauri's lenient_page_flexible into qbz-models and apply it to
RadioResponse.tracks (now non-optional, matching Tauri); derive Default
on Track so the absent-tracks branch resolves to an empty page. Two new
qbz-models tests cover the missing-total and absent-tracks cases.

Wire ArtistView's previously-dead radio dropdown: both items now carry
ArtistState.id and have handler arms -- "QBZ Radio" -> smart pool
builder, "Qobuz Radio" -> the plain /radio/artist endpoint
(play_artist_radio, no longer dead_code). Add a "QBZ track radio"
track-menu item backed by a new play_smart_track_radio that fetches the
track to seed its performer.

cc
2026-06-27 13:34:31 -06:00
vicrodh aceaf9b039 feat(purchases): add purchase wire models + lenient deserializers
Slice 1/13 of the Purchases Tauri->Slint port. Adds PurchaseResponse,
PurchaseIdsResponse, PurchaseAlbum, PurchaseTrack (no version field) and
PurchaseFormatOption to qbz-models, plus the 5 lenient deserializers ported
verbatim from src-tauri api/models.rs. Derives Default on SearchResultsPage
for the empty-page fallback. 4 round-trip tests, cargo test green.

cc
2026-06-24 01:48:34 -06:00
vicrodh 391cbb86b9 feat(slint): ArtistView overhaul — server-driven buckets, dedicated discography, sort/load-more, Magazine, image fixes
Bucketing now trusts the server release_type: drop the foreign-artist filter
that hid band/guest releases (e.g. Vicky Psarakis' Albums were credited to
Sicksense), render every non-empty bucket, hide 'Purchase Only' (download),
and move 'Other' to the end, collapsed by default. Drop the misleading
per-section item counts.

New 'Latest release' highlight (last_release) and 'Appears On' track section
(tracks_appears_on, previously mislabeled as Compilations).

Per-section persisted 5-mode sort (artist_prefs) + per-section load-more
capped at 4 pages, reusing get_releases_grid. New dedicated discography page
(ArtistReleasesView + artist_releases controller) with sort + infinite scroll,
reached via each section's 'See discography' link.

Magazine/Stories sidebar tab (artist/story) with 1:1 thumbnails and a
Network|Magazine tab switcher; hide the empty similar-artists block to remove
the 'no similar' over populated-discovery incoherence. Header Share action.
Per-release award ribbons.

Fix custom artist AND album images: decode + apply immediately on upload and
resolve the local override on navigation, so a custom image now shows for
artists/albums with no Qobuz artwork.

Data layer: type last_release as PageArtistRelease, add /artist/story endpoint
+ core/client methods + ArtistStory* models, export ArtistReleases* globals.

cc
2026-06-21 21:42:22 -06:00
vicrodh fdf2fec3bb feat(core): shared external-stream asset resolver for Cast (Phase 0)
Add a frontend-agnostic API to resolve a track's bytes + MIME + actually-
delivered quality for an external renderer (Chromecast / DLNA), so the Slint
Cast port does not need an improvised downloader (ADR-006).

- qbz-models: ExternalStreamAsset { bytes, content_type, quality, duration,
  origin }, StreamQualityInfo (kHz-normalized via from_raw, handles the
  kHz-vs-Hz endpoint ambiguity), AssetOrigin.
- qbz-qobuz/cmaf: download_full_with_quality(_progress) returns the resolved
  format_id/sampling_rate/bit_depth alongside the FLAC bytes; download_full is
  now a thin wrapper (no duplicated segment assembly).
- qbz-player: Player::fetch_for_external_stream — CMAF-first (FLAC, accurate
  quality) with legacy stream-URL fallback; never reads the local playback
  cache (casting attempts the best tier and must report the real delivered
  quality); never serves an empty content type (external_content_type helper).
- qbz-core: CoreBridge::fetch_for_external_stream_resolved wrapper.

Decision D3 (attempt UltraHiRes first + surface delivered quality) is satisfied
by passing Quality::UltraHiRes and returning StreamQualityInfo. Unit-tested:
content-type fallback by format id, sampling-rate unit normalization.

cc
2026-06-18 09:19:38 -06:00
vicrodh ee83734fba feat(discover): add get_dynamic_suggest_full carrying track_to_analysed
Tauri's DailyQ/WeeklyQ /dynamic/suggest seeds a track_to_analysed payload
(~9 resolved {track_id,artist_id,genre_id,label_id} tuples) for the PRIMARY
mix algorithm; the empty-analysis call is only its fallback. The Slint port
only ever sent the empty-analysis path. Add a TrackToAnalyse model
(qbz-models) and a get_dynamic_suggest_full method on the qobuz client and
qbz-core that carries the payload; the existing get_dynamic_suggest now
delegates with an empty analysis (behaviour unchanged). Plumbing only — the
mix.rs build of the analysis tuples + the two-call fallback lands next (a2).

cc
2026-06-15 15:50:41 -06:00
vicrodh a680da5ad8 perf(slint): server-transcoded Plex thumbs + shared HTTP client for images
Plex cover images were fetched full-res on every surface (Slint built the raw
/library/.../thumb URL), where Tauri requests a server-transcoded thumb sized to
the render target. On a real library this made the views feel heavy and starved
the LAN bandwidth that track playback needs.

- qbz-models: ArtworkRef::PlexThumb gains a size: Option<u32>; new plex_thumb_url()
  emits the Tauri-format transcode URL when Some
  (/photo/:/transcode?url={enc}&width=N&height=N&minSize=1&X-Plex-Token=), raw
  tokenized path when None (album/MPRIS full-res preserved). urlencoding dep added
  to match encodeURIComponent. Tests extended.
- qbz-slint: each PlexThumb construction site passes its surface decode size
  (grid/detail 264, queue/now-playing-item 96, now-playing bar 160, MPRIS full-res);
  fetch_and_decode_ref uses plex_thumb_url.
- artwork.rs: static LazyLock<reqwest::Client> reused in fetch_cached_http instead
  of reqwest::get (per-image client) — keep-alive + no fd churn (EMFILE smell).

Audio path untouched; local/Qobuz artwork unchanged.

cc
2026-06-08 09:03:37 -06:00
vicrodh 381c0b1707 feat(slint): source-aware Plex artwork on now-playing bar, queue panel, MPRIS (port slice 4)
A Plex queue track's artwork was file://-prefixed onto its raw /library/ thumb
path, breaking the cover on all three surfaces. Approach (b) from the funnel
review: keep the raw path in QueueTrack.artwork_url (token never persisted) and
resolve to a fresh tokenized PlexThumb from live plex_settings creds at each
surface.

- qbz-models: additive QueueTrack::artwork_ref_with_plex(base_url, token) — a raw
  /library or /photo path + creds => ArtworkRef::PlexThumb, else falls back to
  artwork_ref() (http/file:// unchanged). 4 regression tests.
- playback.rs: local_queue_track keeps the raw path for Plex rows (no file://
  poison); refresh_now_playing_meta uses artwork_ref_with_plex -> fixes the
  now-playing bar, MPRIS (to_mpris_url), and the desktop notification.
- queue.rs: load_artwork threads creds + a PlexThumb branch for the queue panel.
- Local/Qobuz/ephemeral artwork byte-for-byte unchanged.

cc
2026-06-08 08:32:06 -06:00
vicrodh 2a2328e6c6 feat(slint): MPRIS album art (mpris:artUrl) — Qobuz pass-through + local file://
Port the Tauri normalizeCoverUrlForMetadata workaround as ArtworkRef::to_mpris_url
(qbz-models): remote Qobuz covers pass through as-is, local-file covers become a
percent-encoded file:// URI (MPRIS clients cannot read bare paths or asset://),
Plex thumbs become a tokenized HTTP URL. Wired into the Slint now-playing push so
the media widget shows album art. The app icon was already fixed via DesktopEntry.

cc
2026-06-06 11:58:17 -06:00
vicrodh c657b80271 refactor(qconnect): tighten cast-predicate test, dedupe test fixture, document shared-gate intent
Cross-check test now pins admission to is_castable_to_qconnect (incl. OfflineCache)
instead of the streamable predicate. Dedupe the queuePlaybackService test builder.
Document is_castable_to_qconnect/from_source_str_strict/TrackOriginTag as the shared
QConnect-admission gate consumed by the Slint port.

cc
2026-06-01 02:00:12 -06:00
vicrodh a16d6d0c81 qconnect: allow offline-cache to be cast (one consistent rule)
Three sites disagreed: model blocked OfflineCache, live gate allowed it, TS
sync filter blocked qobuz_download. Decision: ALLOW — an offline copy's queue
id is the real Qobuz track id. Flip the TS sync filter and fix the stale model
doc comment to match the live gate.

Also add Rust lock-in tests (offline_cache_is_castable / offline_cache_is_admitted)
and restore the missing $app/environment vitest mock the config already aliases,
so queuePlaybackService.test.ts can load.

cc
2026-06-01 01:52:22 -06:00
vicrodh 499b77879d qconnect: strict source parse for admission (unknown -> blocked)
Admission must do the opposite of from_source_str: add from_source_str_strict
returning TrackOriginTag::ExternalUnknown so an untagged local/Plex row can
never be cast.

cc
2026-06-01 01:49:15 -06:00
vicrodh f3686c197a qconnect: make PlaybackSource the shared cast predicate (is_castable_to_qconnect)
is_qobuz_streamable was dead code. Add is_castable_to_qconnect as the
single source of truth and depend qconnect-core on qbz-models so the
admission gate and the cast predicate derive from one enum.

cc
2026-06-01 01:48:42 -06:00
vicrodh 827ba9d77a feat(playback): source-aware artwork resolution (ArtworkRef + PlaybackSource)
Add the frontend-agnostic contract for the source-aware playback context:

- qbz-models: new `source` module with `PlaybackSource` (Qobuz / OfflineCache
  / Local / Plex; parses the existing stringly-typed `source` field, defaults
  to Qobuz) and `ArtworkRef` (Remote / LocalFile / PlexThumb / Embedded /
  None). `QueueTrack::source_kind()` + `QueueTrack::artwork_ref()` derive both
  without changing the wire format. `PlaybackSource::is_qobuz_streamable()`
  seeds the Qobuz Connect queue gate (only Qobuz-sourced tracks are castable).

- qbz-slint artwork pipeline: `fetch_and_decode_ref(&ArtworkRef, ...)` resolves
  cover bytes per origin — HTTP/Plex through the disk cache, local files read
  directly, embedded bytes in place. This fixes the root cause of local/Plex
  artwork never reaching the UI (the loader was HTTP-only). The URL-only
  `fetch_and_decode` becomes a thin wrapper, so all existing card/row jobs are
  unchanged. The now-playing loader now takes an `ArtworkRef` derived from the
  queue track, so local-library/Plex covers reach the now-playing bar.

Groundwork for the Local Library Slint port (source-aware playback context
designed in from the start). qbz-models tests pass; qbz-slint cargo check green.

cc
2026-05-28 16:36:02 -06:00
vicrodh 4dddd36073 feat(slint): per-context genre filter + favorites genre filtering
Make genre_filter.rs selection per-context (HashMap keyed by "discover" /
"favorites" + a current context the popup edits), so favorites filters
independently from Discover. Discover keeps its server-side path
unchanged (reads filter_ids("discover") explicitly; its genre buttons set
the "discover" context on open). Favorites get a genre button in both
toolbars that opens the shared popup in the "favorites" context, plus the
popup overlay; derive_tracks / derive_albums filter client-side by the
selected genre NAMES (selected_names expands parents to descendants).
on_toggle / on_clear branch on the current context to re-derive favorites
instead of re-fetching Discover. AlbumSummary gains an optional `genre`
(favorites track album objects carry it; TrackItem can't, so a track
id->genre map backs the tracks filter). Persisted per-context (legacy
flat selection migrates to "discover").

cc
2026-05-27 08:03:56 -06:00
vicrodh 12048bcee0 fix(slint): label albums missing artist/quality/date + favorite + load-more
/label/getAlbums returns the V2 nested album shape (DiscographyAlbumDto:
artists[], audio_info, dates, track_count) — confirmed against the Android
decompiled sources — but it was deserialized into the flat Album model,
which dropped artist, quality and release date (Unknown Artist in
group-by-artist, no quality badge, no date, empty list-row columns).

qbz-models Album: capture the nested V2 fields (audio_info, dates,
track_count, release_type) as optional serde(default) — backward
compatible with the flat-shape endpoints.
label.rs map_album: read nested-first (quality from audio_info, date from
dates, count from track_count, artist from artists[] main-artist, explicit
release_type) with flat fallback. Fixes the releases grid + list columns,
the landing Releases carousel, and Critics' Picks.
Wire ("album","favorite") -> add_favorite + toast (works on every album
card via the shared component).
LabelReleases load-more now driven by has_more (the endpoint caps each
page well below the catalog, so total was an unreliable gate).

cc
2026-05-26 20:10:09 -06:00
vicrodh 85223b03ff perf(slint): virtualize playlist track list + lighter artwork
Large playlists (2000+ tracks) were sluggish. Addressed the root
causes; this strategy is reusable for the library / mixtapes / tracks
views.

  - Virtualization: the track list is now a ListView (only visible
    rows are instantiated) instead of a Flickable with a for over all
    rows. The header + column header are pinned above it.
  - Smaller images: track rows use ImageSet::smallest() (new) instead
    of best() — they are 40px thumbnails, so best() was downloading
    mega/large covers.
  - Per-target decode size: row thumbnails decode to 96px instead of
    the 264px card size, so a 2000-row model holds ~75MB of pixels
    rather than ~550MB.
  - Track-number column widened to fit 4-digit positions.
  - Back-to-top button: appears once scrolled past one viewport
    (bottom-right), scrolls the list home. A shared rule for big lists.
  - Custom-artwork buttons only show on cover hover.

cc
2026-05-22 09:27:45 -06:00
vicrodh c19a5b84dd feat: For You — radio, album suggestions, rediscover, more-from-library
Completes the remaining For You sections by porting two Qobuz API
endpoints down into qbz-core and adding the local "forgotten
favorites" approximation.

Backend ports (qbz-models + qbz-qobuz + qbz-core):
  - /album/suggest -> AlbumSuggestResponse, QobuzClient::get_album_suggest,
    QbzCore::get_album_suggest.
  - /radio/artist + /radio/album -> RadioResponse, get_radio_artist /
    get_radio_album on the client + core. (Faithful ports of the legacy
    src-tauri/api/client.rs methods — same paths, params, shapes.)

For You sections:
  - Radio Stations — album-seeded radio tiles (recent + favorite
    albums, deduped, capped 12), rendered with RadioCard; click starts
    a real Qobuz album radio.
  - More From Your Library — /album/suggest seeded from the most recent
    played album (else first favorite).
  - Rediscover Your Library — favorite albums not in the local
    play-history (approximation of Tauri's reco-DB forgotten
    favorites).
  - Spotlight's RADIO card + the artist "radio" action now create a
    real Qobuz artist radio (playback::play_artist_radio /
    play_album_radio build a queue from the radio track list and play).

Section order now matches Tauri: Qobuz Mixes, Release Watch, Radio
Stations, Recently Played Tracks/Albums, Your Top Artists, More From
Your Library, Rediscover, Artists to Follow, Spotlight.

Still backend-blocked: the Qobuz mix detail views (DailyQ/WeeklyQ/etc.)
and the smart radio_engine (the Qobuz radio used here is the simpler
/radio/* endpoint, not the local pool builder).

cc
2026-05-21 06:43:53 -06:00
vicrodh 1e17032d03 feat(models): move combined-search result types to qbz-models
SearchAllResults and MostPopularItem now live in qbz-models; the Tauri
helpers re-export them under the legacy V2 aliases so command code and
its response serialization are unchanged.

cc
2026-05-19 10:51:14 -06:00
vicrodh 3903086f36 fix(release-watch): drop upcoming and non-streamable albums
`/favorite/getNewReleases` includes albums whose stream availability is
still in the future, so they appear in the Release Watch grid even
though `get_album` 404s as soon as the user clicks them. Same shape for
regional restrictions and label takedowns surfaced via `streamable=false`.

Add `release_date_stream` and `streamable` to the Album struct (both
optional with serde defaults so existing call sites stay happy) and
filter the response inside `v2_get_release_watch` after the artist
blacklist pass: skip any album marked `streamable=false` or whose
`release_date_stream` is strictly greater than today (UTC). The total
count gets adjusted alongside so pagination math stays consistent.

cc
2026-05-15 00:35:38 -06:00
vicrodh e8d58196c5 feat(album): plumb featured artists and parental warning
Add Album.parental_warning and Album.artists (with the new AlbumArtist
struct) to qbz-models so the /album/get response surfaces both fields.
Mirror them in the QobuzAlbum / AlbumDetail TypeScript types and
populate featuredArtists (filtered to non-main contributors) and
parentalWarning in convertQobuzAlbum. Wire onFeaturedArtistClick from
the route so featured artist names route to their discography.

cc
2026-05-06 22:06:54 -06:00
vicrodh 3fe06195c6 feat(queue): expose stop-after marker through QueueState
The marker becomes part of the queue state snapshot the frontend
already consumes via syncQueueState(). Single source of truth — no
parallel event channel, no separate store on the frontend.

Refs #356.

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

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

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

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

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

cc
2026-04-27 18:45:17 -06:00
vicrodh 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 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 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 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 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
vicrodh b738c27507 fix(home): plumb album awards through backend + invalidate stale home cache
The ribbon feature relied on album.awards arriving on the frontend, but
the DiscoverAlbum Rust struct in both qbz-models and src-tauri/api was
missing the 'awards' field, so serde silently dropped it during
deserialization of /discover/index. Result: frontend always saw an
empty awards array and pickAlbumRibbon returned undefined.

Also bumps the home sessionStorage cache to schemaVersion 2 so pre-fix
caches (which persisted AlbumCardData without ribbon) are discarded on
next launch instead of rehydrating with missing fields.

cc
2026-04-14 10:32:28 -06:00
vicrodh 0265cb996c feat(qobuz): add session/start and file/url CMAF endpoints
New CMAF session management with lazy renewal and file/url endpoint
returning segmented encrypted streaming URLs.

cc
2026-04-06 13:19:57 -06:00
vicrodh 8133931df5 feat(models): add SessionStartResponse and TrackFileUrl types
New response models for the CMAF streaming API endpoints
(session/start and file/url).

cc
2026-04-06 13:15:54 -06:00
vicrodh de29f1e2e0 chore(qconnect): checkpoint current poc state
cx
2026-03-13 17:28:05 -06:00
vicrodh d7fd24b2e9 fix: add quality fallback when CDN fails at current quality level
When download fails after retry at the same quality (e.g. HiRes),
automatically try the next lower quality (e.g. Lossless). This handles
CDN edges that consistently serve broken responses for specific tracks
at certain quality levels. Applied to all three download paths: standard
download, streaming probe fallback, and prefetch. Also fixes prefetch
retry using wrong quality variable.
2026-03-12 09:15:57 -06:00
vicrodh 5b9f5a75c2 feat(ui): add explicit content badge across entire app
Display parental_warning badge (E icon) next to track titles in all
views: TrackRow, QueuePanel, NowPlayingBar, ImmersivePlayer with all
13 visualizer panels, MiniPlayer surfaces, and AlbumDetailView.

Propagate parental_warning field through the full data chain from
Rust backend (qbz-models, queue, commands_v2, session_store, API
server) to frontend stores, adapters, and UI components. Badge uses
CSS mask technique with explicit.svg for theme compatibility.
2026-03-06 08:48:51 -06:00