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
This commit is contained in:
vicrodh committed 2026-04-27 18:45:17 -06:00
1 parent 532ec89095
commit 6ff7aab61e
20 files changed
+118 -12

No files matched your search

+4
View File
@@ -15,6 +15,10 @@ use serde::{Deserialize, Serialize};
pub struct QueueTrack { pub struct QueueTrack {
pub id: u64, pub id: u64,
pub title: String, 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<String>,
pub artist: String, pub artist: String,
pub album: String, pub album: String,
pub duration_secs: u64, pub duration_secs: u64,
+5
View File
@@ -193,6 +193,11 @@ pub struct Track {
pub id: u64, pub id: u64,
#[serde(default)] #[serde(default)]
pub title: String, 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<String>,
pub isrc: Option<String>, pub isrc: Option<String>,
#[serde(default)] #[serde(default)]
pub duration: u32, pub duration: u32,
+4
View File
@@ -248,6 +248,10 @@ pub struct Track {
pub id: u64, pub id: u64,
#[serde(default)] #[serde(default)]
pub title: String, 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<String>,
pub isrc: Option<String>, pub isrc: Option<String>,
#[serde(default)] #[serde(default)]
pub duration: u32, pub duration: u32,
@@ -1746,6 +1746,7 @@ pub async fn v2_create_infinite_radio(
tracks.push(super::queue::V2QueueTrack { tracks.push(super::queue::V2QueueTrack {
id: core.id, id: core.id,
title: core.title, title: core.title,
version: core.version,
artist: core.artist, artist: core.artist,
album: core.album, album: core.album,
duration_secs: core.duration_secs, duration_secs: core.duration_secs,
@@ -1795,6 +1796,7 @@ fn track_to_queue_track_from_api(track: &crate::api::Track) -> CoreQueueTrack {
CoreQueueTrack { CoreQueueTrack {
id: track.id, id: track.id,
title: track.title.clone(), title: track.title.clone(),
version: track.version.clone(),
artist, artist,
album, album,
duration_secs: track.duration as u64, duration_secs: track.duration as u64,
+6
View File
@@ -427,6 +427,10 @@ mod tests {
pub struct V2QueueTrack { pub struct V2QueueTrack {
pub id: u64, pub id: u64,
pub title: String, 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<String>,
pub artist: String, pub artist: String,
pub album: String, pub album: String,
pub duration_secs: u64, pub duration_secs: u64,
@@ -462,6 +466,7 @@ impl From<V2QueueTrack> for CoreQueueTrack {
Self { Self {
id: t.id, id: t.id,
title: t.title, title: t.title,
version: t.version,
artist: t.artist, artist: t.artist,
album: t.album, album: t.album,
duration_secs: t.duration_secs, duration_secs: t.duration_secs,
@@ -485,6 +490,7 @@ impl From<CoreQueueTrack> for V2QueueTrack {
Self { Self {
id: t.id, id: t.id,
title: t.title, title: t.title,
version: t.version,
artist: t.artist, artist: t.artist,
album: t.album, album: t.album,
duration_secs: t.duration_secs, duration_secs: t.duration_secs,
+6
View File
@@ -234,6 +234,7 @@ async fn resolve_qobuz_album(
CoreQueueTrack { CoreQueueTrack {
id: track.id, id: track.id,
title: track.title.clone(), title: track.title.clone(),
version: track.version.clone(),
artist, artist,
album: album_title.clone(), album: album_title.clone(),
duration_secs: track.duration as u64, duration_secs: track.duration as u64,
@@ -377,6 +378,7 @@ fn track_to_queue_track_from_api(track: &crate::api::Track) -> CoreQueueTrack {
CoreQueueTrack { CoreQueueTrack {
id: track.id, id: track.id,
title: track.title.clone(), title: track.title.clone(),
version: track.version.clone(),
artist, artist,
album, album,
duration_secs: track.duration as u64, duration_secs: track.duration as u64,
@@ -412,6 +414,7 @@ fn plex_cached_track_to_queue_track(track: &crate::plex::PlexCachedTrack) -> Cor
CoreQueueTrack { CoreQueueTrack {
id, id,
title: track.title.clone(), title: track.title.clone(),
version: None,
artist: track.artist.clone(), artist: track.artist.clone(),
album: track.album.clone(), album: track.album.clone(),
duration_secs: track.duration_secs, 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. // Local ids start from 1 and are never negative in practice.
id: track.id as u64, id: track.id as u64,
title: track.title.clone(), title: track.title.clone(),
version: None,
artist: track.artist.clone(), artist: track.artist.clone(),
album: track.album_group_title.clone(), album: track.album_group_title.clone(),
duration_secs: track.duration_secs, duration_secs: track.duration_secs,
@@ -489,6 +493,7 @@ mod tests {
.map(|i| CoreQueueTrack { .map(|i| CoreQueueTrack {
id: i as u64, id: i as u64,
title: format!("{}-t{}", item.title, i), title: format!("{}-t{}", item.title, i),
version: None,
artist: item.subtitle.clone().unwrap_or_default(), artist: item.subtitle.clone().unwrap_or_default(),
album: item.title.clone(), album: item.title.clone(),
duration_secs: 180, duration_secs: 180,
@@ -584,6 +589,7 @@ mod tests {
CoreQueueTrack { CoreQueueTrack {
id: 0, id: 0,
title: "t".into(), title: "t".into(),
version: None,
artist: "a".into(), artist: "a".into(),
album: "alb".into(), album: "alb".into(),
duration_secs: 100, duration_secs: 100,
+1
View File
@@ -347,6 +347,7 @@ mod tests {
CoreQueueTrack { CoreQueueTrack {
id, id,
title: title.to_string(), title: title.to_string(),
version: None,
artist: artist.to_string(), artist: artist.to_string(),
album: String::new(), album: String::new(),
duration_secs: 0, duration_secs: 0,
+1
View File
@@ -125,6 +125,7 @@ pub(super) fn model_track_to_core_queue_track(track: &Track) -> QueueTrack {
QueueTrack { QueueTrack {
id: track.id, id: track.id,
title: track.title.clone(), title: track.title.clone(),
version: track.version.clone(),
artist, artist,
album, album,
duration_secs: track.duration as u64, duration_secs: track.duration as u64,
+1
View File
@@ -376,6 +376,7 @@ export function convertQobuzAlbum(album: QobuzAlbum): AlbumDetail {
id: track.id, id: track.id,
number: index + 1, number: index + 1,
title: track.title, title: track.title,
version: track.version ?? null,
artist: track.performer?.name, artist: track.performer?.name,
duration: formatDuration(track.duration), duration: formatDuration(track.duration),
durationSeconds: track.duration, durationSeconds: track.duration,
+4 -3
View File
@@ -9,6 +9,7 @@
subscribe as subscribeFavorites subscribe as subscribeFavorites
} from '$lib/stores/favoritesStore'; } from '$lib/stores/favoritesStore';
import { getUserItem, setUserItem } from '$lib/utils/userStorage'; import { getUserItem, setUserItem } from '$lib/utils/userStorage';
import { formatTrackTitle } from '$lib/utils/trackTitle';
interface QueueTrack { interface QueueTrack {
id: string; id: string;
@@ -314,7 +315,7 @@
</div> </div>
<div class="np-info"> <div class="np-info">
<div class="np-title-row"> <div class="np-title-row">
<span class="np-title">{currentTrack.title}</span> <span class="np-title">{formatTrackTitle(currentTrack)}</span>
{#if currentTrack.parental_warning} {#if currentTrack.parental_warning}
<span class="explicit-badge" title={ $t('library.explicit') }></span> <span class="explicit-badge" title={ $t('library.explicit') }></span>
{/if} {/if}
@@ -367,7 +368,7 @@
<span class="track-number">{originalIndex + 1}</span> <span class="track-number">{originalIndex + 1}</span>
<div class="track-info"> <div class="track-info">
<div class="track-title-row"> <div class="track-title-row">
<span class="track-title">{queueTrack.title}</span> <span class="track-title">{formatTrackTitle(queueTrack)}</span>
{#if queueTrack.parental_warning} {#if queueTrack.parental_warning}
<span class="explicit-badge" title={ $t('library.explicit') }></span> <span class="explicit-badge" title={ $t('library.explicit') }></span>
{/if} {/if}
@@ -433,7 +434,7 @@
> >
<img use:cachedSrc={track.artwork} alt={track.title} class="history-artwork" /> <img use:cachedSrc={track.artwork} alt={track.title} class="history-artwork" />
<div class="track-info"> <div class="track-info">
<div class="track-title">{track.title}</div> <div class="track-title">{formatTrackTitle(track)}</div>
<div class="track-artist">{track.artist}</div> <div class="track-artist">{track.artist}</div>
</div> </div>
<span class="track-duration">{track.duration}</span> <span class="track-duration">{track.duration}</span>
@@ -4,6 +4,7 @@
import type { OfflineCacheStatus } from '$lib/stores/offlineCacheState'; import type { OfflineCacheStatus } from '$lib/stores/offlineCacheState';
import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore'; import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore';
import { restoreScrollOnBackForward } from '$lib/utils/scrollRestore'; import { restoreScrollOnBackForward } from '$lib/utils/scrollRestore';
import { formatTrackTitle } from '$lib/utils/trackTitle';
// Use generic types to match whatever caller passes // Use generic types to match whatever caller passes
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -98,7 +99,7 @@
// Optional accessors with defaults for LocalTrack structure // Optional accessors with defaults for LocalTrack structure
getTrackId = (track: Track) => track.id, getTrackId = (track: Track) => track.id,
getTrackNumber = (track: Track, idx: number) => track.track_number ?? idx + 1, 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, getTrackArtist = (track: Track) => track.artist,
getTrackDuration = (track: Track) => track.duration_secs, getTrackDuration = (track: Track) => track.duration_secs,
getTrackAlbumKey = (track: Track) => track.album_group_key, getTrackAlbumKey = (track: Track) => track.album_group_key,
@@ -13,6 +13,7 @@
} from '$lib/stores/customAlbumCoverStore'; } from '$lib/stores/customAlbumCoverStore';
import { ArrowLeft, Play, Shuffle, Heart, Radio, CloudDownload, ChevronLeft, ChevronRight, LoaderCircle, SquareCheckBig, BookOpen, Disc3, CassetteTape } from 'lucide-svelte'; import { ArrowLeft, Play, Shuffle, Heart, Radio, CloudDownload, ChevronLeft, ChevronRight, LoaderCircle, SquareCheckBig, BookOpen, Disc3, CassetteTape } from 'lucide-svelte';
import { openAddToMixtape } from '$lib/stores/addToMixtapeModalStore'; import { openAddToMixtape } from '$lib/stores/addToMixtapeModalStore';
import { formatTrackTitle } from '$lib/utils/trackTitle';
import { cachedSrc } from '$lib/actions/cachedImage'; import { cachedSrc } from '$lib/actions/cachedImage';
import AlbumCard from '../AlbumCard.svelte'; import AlbumCard from '../AlbumCard.svelte';
import TrackRow from '../TrackRow.svelte'; import TrackRow from '../TrackRow.svelte';
@@ -38,6 +39,8 @@
id: number; id: number;
number: number; number: number;
title: string; title: string;
/** Qobuz subtitle/edition (e.g. "Player's Ball Mix") (#360). */
version?: string | null;
artist?: string; artist?: string;
artistId?: number; artistId?: number;
duration: string; duration: string;
@@ -262,6 +265,7 @@
return tracks.map(trk => ({ return tracks.map(trk => ({
id: trk.id, id: trk.id,
title: trk.title, title: trk.title,
version: trk.version ?? null,
artist: trk.artist || album.artist, artist: trk.artist || album.artist,
album: album.title, album: album.title,
duration_secs: trk.durationSeconds, duration_secs: trk.durationSeconds,
@@ -731,7 +735,7 @@
<TrackRow <TrackRow
trackId={track.id} trackId={track.id}
number={track.number} number={track.number}
title={track.title} title={formatTrackTitle(track)}
artist={track.artist} artist={track.artist}
duration={track.duration} duration={track.duration}
quality={track.quality} quality={track.quality}
@@ -2,6 +2,7 @@
import { ArrowLeft, Play, Shuffle, ListMusic, Search, X, ChevronDown, ChevronRight, ChevronUp, ImagePlus, PenLine, ChartNoAxesColumn, Heart, CloudDownload, ListPlus, GripVertical, SquareCheckBig, Bookmark } from 'lucide-svelte'; import { ArrowLeft, Play, Shuffle, ListMusic, Search, X, ChevronDown, ChevronRight, ChevronUp, ImagePlus, PenLine, ChartNoAxesColumn, Heart, CloudDownload, ListPlus, GripVertical, SquareCheckBig, Bookmark } from 'lucide-svelte';
import AlbumMenu from '../AlbumMenu.svelte'; import AlbumMenu from '../AlbumMenu.svelte';
import { openAddToMixtape } from '$lib/stores/addToMixtapeModalStore'; import { openAddToMixtape } from '$lib/stores/addToMixtapeModalStore';
import { formatTrackTitle } from '$lib/utils/trackTitle';
import PlaylistCollage from '../PlaylistCollage.svelte'; import PlaylistCollage from '../PlaylistCollage.svelte';
import { cachedSrc } from '$lib/actions/cachedImage'; import { cachedSrc } from '$lib/actions/cachedImage';
import PlaylistModal from '../PlaylistModal.svelte'; import PlaylistModal from '../PlaylistModal.svelte';
@@ -2798,7 +2799,7 @@
<TrackRow <TrackRow
trackId={track.isLocal ? undefined : track.id} trackId={track.isLocal ? undefined : track.id}
number={idx + 1} number={idx + 1}
title={track.title} title={formatTrackTitle(track)}
artist={track.artist} artist={track.artist}
album={track.album} album={track.album}
showArtwork={true} showArtwork={true}
+4 -3
View File
@@ -9,6 +9,7 @@
import ViewTransition from '../ViewTransition.svelte'; import ViewTransition from '../ViewTransition.svelte';
import TrackMenu from '../TrackMenu.svelte'; import TrackMenu from '../TrackMenu.svelte';
import { openAddToMixtape } from '$lib/stores/addToMixtapeModalStore'; import { openAddToMixtape } from '$lib/stores/addToMixtapeModalStore';
import { formatTrackTitle } from '$lib/utils/trackTitle';
import QualityBadge from '../QualityBadge.svelte'; import QualityBadge from '../QualityBadge.svelte';
import { formatQuality } from '$lib/adapters/qobuzAdapters'; import { formatQuality } from '$lib/adapters/qobuzAdapters';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService'; import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
@@ -1483,7 +1484,7 @@
style="--ticker-offset: {popularTitleOffset}; --ticker-duration: {popularTitleDuration};" style="--ticker-offset: {popularTitleOffset}; --ticker-duration: {popularTitleDuration};"
bind:this={popularTitleRef} bind:this={popularTitleRef}
> >
<span class="popular-title-text" bind:this={popularTitleTextRef}>{track.title}</span> <span class="popular-title-text" bind:this={popularTitleTextRef}>{formatTrackTitle(track)}</span>
</div> </div>
<div <div
class="popular-card-subtitle" class="popular-card-subtitle"
@@ -1722,7 +1723,7 @@
</button> </button>
</div> </div>
<div class="track-info"> <div class="track-info">
<div class="track-title">{track.title}</div> <div class="track-title">{formatTrackTitle(track)}</div>
{#if track.performer?.id && onTrackGoToArtist} {#if track.performer?.id && onTrackGoToArtist}
<button <button
class="track-artist track-link" class="track-artist track-link"
@@ -1930,7 +1931,7 @@
</button> </button>
</div> </div>
<div class="track-info"> <div class="track-info">
<div class="track-title">{track.title}</div> <div class="track-title">{formatTrackTitle(track)}</div>
{#if track.performer?.id && onTrackGoToArtist} {#if track.performer?.id && onTrackGoToArtist}
<button <button
class="track-artist track-link" class="track-artist track-link"
+3
View File
@@ -207,6 +207,7 @@ export function buildQueueTrackFromQobuz(track: QobuzTrack): BackendQueueTrack {
return { return {
id: track.id, id: track.id,
title: track.title, title: track.title,
version: track.version ?? null,
artist: track.performer?.name || 'Unknown Artist', artist: track.performer?.name || 'Unknown Artist',
album: track.album?.title || '', album: track.album?.title || '',
duration_secs: track.duration, duration_secs: track.duration,
@@ -239,6 +240,7 @@ export function buildQueueTrackFromAlbumTrack(
return { return {
id: track.id, id: track.id,
title: track.title, title: track.title,
version: track.version ?? null,
artist: track.artist || albumArtist || 'Unknown Artist', artist: track.artist || albumArtist || 'Unknown Artist',
album: albumTitle || '', album: albumTitle || '',
duration_secs: track.durationSeconds, duration_secs: track.durationSeconds,
@@ -264,6 +266,7 @@ export function buildQueueTrackFromPlaylistTrack(track: PlaylistTrack): BackendQ
return { return {
id: track.id, id: track.id,
title: track.title, title: track.title,
version: track.version ?? null,
artist: track.artist || 'Unknown Artist', artist: track.artist || 'Unknown Artist',
album: track.album || 'Playlist', album: track.album || 'Playlist',
duration_secs: track.durationSeconds, duration_secs: track.durationSeconds,
+6
View File
@@ -41,6 +41,9 @@ import { syncQueueState } from '$lib/stores/queueStore';
export interface PlayingTrack { export interface PlayingTrack {
id: number; id: number;
title: string; title: string;
/** Qobuz subtitle/edition info (e.g. "Player's Ball Mix"). Render
* via formatTrackTitle() in UI components (#360). */
version?: string | null;
artist: string; artist: string;
album: string; album: string;
artwork: string; artwork: string;
@@ -96,6 +99,8 @@ interface PlaybackEvent {
interface QueueTrack { interface QueueTrack {
id: number; id: number;
title: string; title: string;
/** Subtitle/edition info from Qobuz (e.g. "Player's Ball Mix") (#360). */
version?: string | null;
artist: string; artist: string;
album: string; album: string;
duration_secs: number; duration_secs: number;
@@ -691,6 +696,7 @@ async function handlePlaybackEvent(event: PlaybackEvent): Promise<void> {
currentTrack = { currentTrack = {
id: queueTrack.id, id: queueTrack.id,
title: queueTrack.title, title: queueTrack.title,
version: queueTrack.version ?? null,
artist: queueTrack.artist, artist: queueTrack.artist,
album: queueTrack.album, album: queueTrack.album,
artwork: queueTrack.artwork_url || '', artwork: queueTrack.artwork_url || '',
+9
View File
@@ -14,6 +14,10 @@ export interface QueueTrack {
id: string; id: string;
artwork: string; artwork: string;
title: string; title: string;
/** Qobuz subtitle/edition (e.g. "Player's Ball Mix"). Render with
* formatTrackTitle() so remix/reissue albums are distinguishable
* from originals (#360). */
version?: string | null;
artist: string; artist: string;
duration: string; duration: string;
available?: boolean; // Whether track is available (false when offline without local copy) available?: boolean; // Whether track is available (false when offline without local copy)
@@ -24,6 +28,10 @@ export interface QueueTrack {
export interface BackendQueueTrack { export interface BackendQueueTrack {
id: number; id: number;
title: string; title: string;
/** Subtitle/edition info from Qobuz (e.g. "Player's Ball Mix"). Render
* with formatTrackTitle() so remix/reissue albums are distinguishable
* from originals (issue #360). */
version?: string | null;
artist: string; artist: string;
album: string; album: string;
duration_secs: number; duration_secs: number;
@@ -196,6 +204,7 @@ async function applyBackendQueueState(queueState: BackendQueueState): Promise<vo
id: String(track.id), id: String(track.id),
artwork: track.artwork_url || '', artwork: track.artwork_url || '',
title: track.title, title: track.title,
version: track.version ?? null,
artist: track.artist, artist: track.artist,
duration: formatDuration(track.duration_secs), duration: formatDuration(track.duration_secs),
available: !isOfflineMode || localTrackIds.has(track.id) || localCopies.has(track.id), available: !isOfflineMode || localTrackIds.has(track.id) || localCopies.has(track.id),
+10
View File
@@ -16,6 +16,9 @@ export interface QobuzImage {
export interface QobuzTrack { export interface QobuzTrack {
id: number; id: number;
title: string; title: string;
/** Subtitle/edition info from Qobuz (e.g. "Player's Ball Mix"). Render
* via formatTrackTitle() to disambiguate remixes/reissues (#360). */
version?: string | null;
duration: number; duration: number;
track_number?: number; track_number?: number;
album?: { album?: {
@@ -166,6 +169,9 @@ export interface Track {
id: number; id: number;
number: number; number: number;
title: string; title: string;
/** Qobuz subtitle/edition (e.g. "Player's Ball Mix"). Render with
* formatTrackTitle() (#360). */
version?: string | null;
artist?: string; artist?: string;
duration: string; duration: string;
durationSeconds: number; durationSeconds: number;
@@ -350,6 +356,8 @@ export interface PlaylistTrack {
id: number; id: number;
number: number; number: number;
title: string; title: string;
/** Qobuz subtitle/edition (#360). */
version?: string | null;
artist?: string; artist?: string;
album?: string; album?: string;
albumArt?: string; albumArt?: string;
@@ -374,6 +382,8 @@ export interface DisplayTrack {
id: number; id: number;
number?: number; number?: number;
title: string; title: string;
/** Qobuz subtitle/edition (#360). */
version?: string | null;
artist?: string; artist?: string;
album?: string; album?: string;
albumArt?: string; albumArt?: string;
+32
View File
@@ -0,0 +1,32 @@
/**
* Format a track's display title with its Qobuz subtitle/edition info
* (the `version` field) appended in parentheses when present.
*
* Qobuz returns a separate `version` field for tracks belonging to
* remix albums, reissues, deluxe editions, etc. (e.g. "Player's Ball
* Mix", "Nine Inch Noize Version", "Remastered 2024"). The mobile app
* displays it parenthesized after the title; qbz now mirrors that so
* remix albums are distinguishable from originals.
*
* Issue: https://github.com/vicrodh/qbz/issues/360
*
* @example
* formatTrackTitle({ title: "Player's Ball" })
* // => "Player's Ball"
*
* formatTrackTitle({ title: "Player's Ball", version: "Player's Ball Mix" })
* // => "Player's Ball (Player's Ball Mix)"
*/
export function formatTrackTitle(
track: { title?: string | null; version?: string | null } | null | undefined
): string {
if (!track) {
return '';
}
const title = (track.title ?? '').trim();
const version = (track.version ?? '').trim();
if (!version) {
return title;
}
return `${title} (${version})`;
}
+11 -3
View File
@@ -184,6 +184,7 @@
import { resolveAwardIdByName } from '$lib/stores/awardCatalogStore'; import { resolveAwardIdByName } from '$lib/stores/awardCatalogStore';
import { getDefaultFavoritesTab } from '$lib/utils/favorites'; import { getDefaultFavoritesTab } from '$lib/utils/favorites';
import { platform } from '$lib/utils/platform'; import { platform } from '$lib/utils/platform';
import { formatTrackTitle } from '$lib/utils/trackTitle';
import type { FavoritesPreferences, ResolvedMusician } from '$lib/types'; import type { FavoritesPreferences, ResolvedMusician } from '$lib/types';
// Mixtapes / Collections views and store // Mixtapes / Collections views and store
@@ -1224,6 +1225,7 @@
id: String(track.id), id: String(track.id),
artwork: track.artwork_url || '', artwork: track.artwork_url || '',
title: track.title, title: track.title,
version: track.version ?? null,
artist: track.artist, artist: track.artist,
duration: formatDuration(track.duration_secs), duration: formatDuration(track.duration_secs),
trackId: track.id trackId: track.id
@@ -5478,6 +5480,7 @@
id: String(trk.id), id: String(trk.id),
artwork: trk.artwork_url || '', artwork: trk.artwork_url || '',
title: trk.title, title: trk.title,
version: trk.version ?? null,
artist: trk.artist, artist: trk.artist,
duration: formatDuration(trk.duration_secs), duration: formatDuration(trk.duration_secs),
trackId: trk.id trackId: trk.id
@@ -5521,11 +5524,16 @@
}); });
}); });
// Derived values for NowPlayingBar // Derived values for NowPlayingBar. `version` is propagated raw so
// each render site can call formatTrackTitle() to compose the
// displayed string. Keeping the field separate (instead of pre-
// formatting here) lets components style title vs version
// differently if desired (#360).
const currentQueueTrack = $derived<QueueTrack | null>(currentTrack ? { const currentQueueTrack = $derived<QueueTrack | null>(currentTrack ? {
id: String(currentTrack.id), id: String(currentTrack.id),
artwork: currentTrack.artwork, artwork: currentTrack.artwork,
title: currentTrack.title, title: currentTrack.title,
version: currentTrack.version ?? null,
artist: currentTrack.artist, artist: currentTrack.artist,
duration: formatDuration(currentTrack.duration), duration: formatDuration(currentTrack.duration),
trackId: currentTrack.id // For favorite checking in QueuePanel trackId: currentTrack.id // For favorite checking in QueuePanel
@@ -6484,7 +6492,7 @@
{#if currentTrack} {#if currentTrack}
<NowPlayingBar <NowPlayingBar
artwork={resolvedArtwork} artwork={resolvedArtwork}
trackTitle={currentTrack.title} trackTitle={formatTrackTitle(currentTrack)}
artist={currentTrack.artist} artist={currentTrack.artist}
album={currentTrack.album} album={currentTrack.album}
quality={currentTrack.quality} quality={currentTrack.quality}
@@ -6586,7 +6594,7 @@
if (isFocusModeOpen) closeFocusMode(); if (isFocusModeOpen) closeFocusMode();
}} }}
artwork={resolvedArtwork} artwork={resolvedArtwork}
trackTitle={currentTrack.title} trackTitle={formatTrackTitle(currentTrack)}
artist={currentTrack.artist} artist={currentTrack.artist}
album={currentTrack.album} album={currentTrack.album}
trackId={currentTrack.id} trackId={currentTrack.id}