From 6ff7aab61e775bb7275b0e60f51e6df69013fa35 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:45:17 -0600 Subject: [PATCH] feat(track): show Qobuz subtitle/edition (version) in core renderers (#360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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` 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 --- crates/qbz-models/src/playback.rs | 4 +++ crates/qbz-models/src/types.rs | 5 +++ src-tauri/src/api/models.rs | 4 +++ src-tauri/src/commands_v2/link_resolver.rs | 2 ++ src-tauri/src/commands_v2/queue.rs | 6 ++++ src-tauri/src/mixtape/enqueue.rs | 6 ++++ src-tauri/src/mixtape/shuffle.rs | 1 + src-tauri/src/qconnect/mod.rs | 1 + src/lib/adapters/qobuzAdapters.ts | 1 + src/lib/components/QueuePanel.svelte | 7 ++-- .../components/VirtualizedTrackList.svelte | 3 +- .../components/views/AlbumDetailView.svelte | 6 +++- .../views/PlaylistDetailView.svelte | 3 +- src/lib/components/views/SearchView.svelte | 7 ++-- src/lib/services/trackActions.ts | 3 ++ src/lib/stores/playerStore.ts | 6 ++++ src/lib/stores/queueStore.ts | 9 ++++++ src/lib/types/index.ts | 10 ++++++ src/lib/utils/trackTitle.ts | 32 +++++++++++++++++++ src/routes/+page.svelte | 14 ++++++-- 20 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 src/lib/utils/trackTitle.ts diff --git a/crates/qbz-models/src/playback.rs b/crates/qbz-models/src/playback.rs index 76fdff922..c1c788d94 100644 --- a/crates/qbz-models/src/playback.rs +++ b/crates/qbz-models/src/playback.rs @@ -15,6 +15,10 @@ use serde::{Deserialize, Serialize}; pub struct QueueTrack { pub id: u64, pub title: String, + /// Subtitle/edition info from Qobuz (e.g. "Player's Ball Mix") that + /// the frontend renders parenthesized after the title (issue #360). + #[serde(default)] + pub version: Option, pub artist: String, pub album: String, pub duration_secs: u64, diff --git a/crates/qbz-models/src/types.rs b/crates/qbz-models/src/types.rs index b1c9eb649..974f5d47b 100644 --- a/crates/qbz-models/src/types.rs +++ b/crates/qbz-models/src/types.rs @@ -193,6 +193,11 @@ pub struct Track { pub id: u64, #[serde(default)] pub title: String, + /// Subtitle/edition info from Qobuz (e.g. "Player's Ball Mix", + /// "Nine Inch Noize Version", "Remastered 2024"). Frontend renders + /// it parenthesized after the title so remix and reissue albums are + /// distinguishable from originals (issue #360). + pub version: Option, pub isrc: Option, #[serde(default)] pub duration: u32, diff --git a/src-tauri/src/api/models.rs b/src-tauri/src/api/models.rs index d650375d5..67d747f47 100644 --- a/src-tauri/src/api/models.rs +++ b/src-tauri/src/api/models.rs @@ -248,6 +248,10 @@ pub struct Track { pub id: u64, #[serde(default)] pub title: String, + /// Subtitle/edition info from Qobuz (e.g. "Player's Ball Mix", + /// "Nine Inch Noize Version", "Remastered 2024"). Frontend renders + /// it parenthesized after the title (issue #360). + pub version: Option, pub isrc: Option, #[serde(default)] pub duration: u32, diff --git a/src-tauri/src/commands_v2/link_resolver.rs b/src-tauri/src/commands_v2/link_resolver.rs index 2c922b41e..fc0e4d84d 100644 --- a/src-tauri/src/commands_v2/link_resolver.rs +++ b/src-tauri/src/commands_v2/link_resolver.rs @@ -1746,6 +1746,7 @@ pub async fn v2_create_infinite_radio( tracks.push(super::queue::V2QueueTrack { id: core.id, title: core.title, + version: core.version, artist: core.artist, album: core.album, duration_secs: core.duration_secs, @@ -1795,6 +1796,7 @@ fn track_to_queue_track_from_api(track: &crate::api::Track) -> CoreQueueTrack { CoreQueueTrack { id: track.id, title: track.title.clone(), + version: track.version.clone(), artist, album, duration_secs: track.duration as u64, diff --git a/src-tauri/src/commands_v2/queue.rs b/src-tauri/src/commands_v2/queue.rs index 457d605da..b31ca0239 100644 --- a/src-tauri/src/commands_v2/queue.rs +++ b/src-tauri/src/commands_v2/queue.rs @@ -427,6 +427,10 @@ mod tests { pub struct V2QueueTrack { pub id: u64, pub title: String, + /// Subtitle/edition info from Qobuz (e.g. "Player's Ball Mix"), + /// rendered parenthesized after the title in the frontend (#360). + #[serde(default)] + pub version: Option, pub artist: String, pub album: String, pub duration_secs: u64, @@ -462,6 +466,7 @@ impl From for CoreQueueTrack { Self { id: t.id, title: t.title, + version: t.version, artist: t.artist, album: t.album, duration_secs: t.duration_secs, @@ -485,6 +490,7 @@ impl From for V2QueueTrack { Self { id: t.id, title: t.title, + version: t.version, artist: t.artist, album: t.album, duration_secs: t.duration_secs, diff --git a/src-tauri/src/mixtape/enqueue.rs b/src-tauri/src/mixtape/enqueue.rs index 1a3651a1b..398e06af4 100644 --- a/src-tauri/src/mixtape/enqueue.rs +++ b/src-tauri/src/mixtape/enqueue.rs @@ -234,6 +234,7 @@ async fn resolve_qobuz_album( CoreQueueTrack { id: track.id, title: track.title.clone(), + version: track.version.clone(), artist, album: album_title.clone(), duration_secs: track.duration as u64, @@ -377,6 +378,7 @@ fn track_to_queue_track_from_api(track: &crate::api::Track) -> CoreQueueTrack { CoreQueueTrack { id: track.id, title: track.title.clone(), + version: track.version.clone(), artist, album, duration_secs: track.duration as u64, @@ -412,6 +414,7 @@ fn plex_cached_track_to_queue_track(track: &crate::plex::PlexCachedTrack) -> Cor CoreQueueTrack { id, title: track.title.clone(), + version: None, artist: track.artist.clone(), album: track.album.clone(), duration_secs: track.duration_secs, @@ -450,6 +453,7 @@ pub fn local_track_to_queue_track(track: &qbz_library::LocalTrack) -> CoreQueueT // Local ids start from 1 and are never negative in practice. id: track.id as u64, title: track.title.clone(), + version: None, artist: track.artist.clone(), album: track.album_group_title.clone(), duration_secs: track.duration_secs, @@ -489,6 +493,7 @@ mod tests { .map(|i| CoreQueueTrack { id: i as u64, title: format!("{}-t{}", item.title, i), + version: None, artist: item.subtitle.clone().unwrap_or_default(), album: item.title.clone(), duration_secs: 180, @@ -584,6 +589,7 @@ mod tests { CoreQueueTrack { id: 0, title: "t".into(), + version: None, artist: "a".into(), album: "alb".into(), duration_secs: 100, diff --git a/src-tauri/src/mixtape/shuffle.rs b/src-tauri/src/mixtape/shuffle.rs index e383afb50..7b7a56046 100644 --- a/src-tauri/src/mixtape/shuffle.rs +++ b/src-tauri/src/mixtape/shuffle.rs @@ -347,6 +347,7 @@ mod tests { CoreQueueTrack { id, title: title.to_string(), + version: None, artist: artist.to_string(), album: String::new(), duration_secs: 0, diff --git a/src-tauri/src/qconnect/mod.rs b/src-tauri/src/qconnect/mod.rs index 5c47a3099..5a598f8c6 100644 --- a/src-tauri/src/qconnect/mod.rs +++ b/src-tauri/src/qconnect/mod.rs @@ -125,6 +125,7 @@ pub(super) fn model_track_to_core_queue_track(track: &Track) -> QueueTrack { QueueTrack { id: track.id, title: track.title.clone(), + version: track.version.clone(), artist, album, duration_secs: track.duration as u64, diff --git a/src/lib/adapters/qobuzAdapters.ts b/src/lib/adapters/qobuzAdapters.ts index 9b666cc34..0ce421fc2 100644 --- a/src/lib/adapters/qobuzAdapters.ts +++ b/src/lib/adapters/qobuzAdapters.ts @@ -376,6 +376,7 @@ export function convertQobuzAlbum(album: QobuzAlbum): AlbumDetail { id: track.id, number: index + 1, title: track.title, + version: track.version ?? null, artist: track.performer?.name, duration: formatDuration(track.duration), durationSeconds: track.duration, diff --git a/src/lib/components/QueuePanel.svelte b/src/lib/components/QueuePanel.svelte index 0b9271b19..d125421bb 100644 --- a/src/lib/components/QueuePanel.svelte +++ b/src/lib/components/QueuePanel.svelte @@ -9,6 +9,7 @@ subscribe as subscribeFavorites } from '$lib/stores/favoritesStore'; import { getUserItem, setUserItem } from '$lib/utils/userStorage'; + import { formatTrackTitle } from '$lib/utils/trackTitle'; interface QueueTrack { id: string; @@ -314,7 +315,7 @@
- {currentTrack.title} + {formatTrackTitle(currentTrack)} {#if currentTrack.parental_warning} {/if} @@ -367,7 +368,7 @@ {originalIndex + 1}
- {queueTrack.title} + {formatTrackTitle(queueTrack)} {#if queueTrack.parental_warning} {/if} @@ -433,7 +434,7 @@ > {track.title}
-
{track.title}
+
{formatTrackTitle(track)}
{track.artist}
{track.duration} diff --git a/src/lib/components/VirtualizedTrackList.svelte b/src/lib/components/VirtualizedTrackList.svelte index 9a8810f55..d7cb7edfd 100644 --- a/src/lib/components/VirtualizedTrackList.svelte +++ b/src/lib/components/VirtualizedTrackList.svelte @@ -4,6 +4,7 @@ import type { OfflineCacheStatus } from '$lib/stores/offlineCacheState'; import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore'; import { restoreScrollOnBackForward } from '$lib/utils/scrollRestore'; + import { formatTrackTitle } from '$lib/utils/trackTitle'; // Use generic types to match whatever caller passes // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -98,7 +99,7 @@ // Optional accessors with defaults for LocalTrack structure getTrackId = (track: Track) => track.id, getTrackNumber = (track: Track, idx: number) => track.track_number ?? idx + 1, - getTrackTitle = (track: Track) => track.title, + getTrackTitle = (track: Track) => formatTrackTitle(track), getTrackArtist = (track: Track) => track.artist, getTrackDuration = (track: Track) => track.duration_secs, getTrackAlbumKey = (track: Track) => track.album_group_key, diff --git a/src/lib/components/views/AlbumDetailView.svelte b/src/lib/components/views/AlbumDetailView.svelte index 9fb834f71..7f3806285 100644 --- a/src/lib/components/views/AlbumDetailView.svelte +++ b/src/lib/components/views/AlbumDetailView.svelte @@ -13,6 +13,7 @@ } from '$lib/stores/customAlbumCoverStore'; import { ArrowLeft, Play, Shuffle, Heart, Radio, CloudDownload, ChevronLeft, ChevronRight, LoaderCircle, SquareCheckBig, BookOpen, Disc3, CassetteTape } from 'lucide-svelte'; import { openAddToMixtape } from '$lib/stores/addToMixtapeModalStore'; + import { formatTrackTitle } from '$lib/utils/trackTitle'; import { cachedSrc } from '$lib/actions/cachedImage'; import AlbumCard from '../AlbumCard.svelte'; import TrackRow from '../TrackRow.svelte'; @@ -38,6 +39,8 @@ id: number; number: number; title: string; + /** Qobuz subtitle/edition (e.g. "Player's Ball Mix") (#360). */ + version?: string | null; artist?: string; artistId?: number; duration: string; @@ -262,6 +265,7 @@ return tracks.map(trk => ({ id: trk.id, title: trk.title, + version: trk.version ?? null, artist: trk.artist || album.artist, album: album.title, duration_secs: trk.durationSeconds, @@ -731,7 +735,7 @@ - {track.title} + {formatTrackTitle(track)}
-
{track.title}
+
{formatTrackTitle(track)}
{#if track.performer?.id && onTrackGoToArtist}
-
{track.title}
+
{formatTrackTitle(track)}
{#if track.performer?.id && onTrackGoToArtist}