feat(tracks): add streamable field infrastructure for unavailable tracks

Adds streamable field throughout the stack to detect tracks that have
been removed from Qobuz but still appear in playlists/favorites:

Backend (Rust):
- QueueTrack: add streamable field with default true
- PersistedQueueTrack: add streamable for session persistence
- radio.rs: log warning when non-streamable track is encountered

Frontend:
- BackendQueueTrack: add streamable? field
- QobuzTrack, Track, PlaylistTrack types: add streamable
- qobuzAdapters.ts: include streamable in track conversions
- trackActions.ts: all queue builders now include streamable
- +page.svelte: debug logging includes streamable field

This prepares the infrastructure for graying out unavailable tracks
in the UI (to be implemented when we can reproduce the issue).

Refs: User report about "Failed to fetch audio" for removed tracks
This commit is contained in:
vicrodh committed 2026-01-29 22:50:02 -06:00
1 parent 7ce5b402c4
commit 10788f877f
8 files changed
+79 -5

No files matched your search

+9
View File
@@ -268,6 +268,14 @@ fn track_to_queue_track(track: &Track) -> QueueTrack {
let album_id = track.album.as_ref().map(|a| a.id.clone()); let album_id = track.album.as_ref().map(|a| a.id.clone());
let artist_id = track.performer.as_ref().map(|p| p.id); let artist_id = track.performer.as_ref().map(|p| p.id);
// Log if track is not streamable
if !track.streamable {
log::warn!(
"[Radio] Track not streamable: {} - {} (ID: {})",
artist, track.title, track.id
);
}
QueueTrack { QueueTrack {
id: track.id, id: track.id,
title: track.title.clone(), title: track.title.clone(),
@@ -281,6 +289,7 @@ fn track_to_queue_track(track: &Track) -> QueueTrack {
is_local: false, is_local: false,
album_id, album_id,
artist_id, artist_id,
streamable: track.streamable,
} }
} }
+7
View File
@@ -30,6 +30,13 @@ pub struct QueueTrack {
pub album_id: Option<String>, pub album_id: Option<String>,
/// Artist ID for navigation (Qobuz artist ID) /// Artist ID for navigation (Qobuz artist ID)
pub artist_id: Option<u64>, pub artist_id: Option<u64>,
/// Whether the track is streamable on Qobuz (false = removed/unavailable)
#[serde(default = "default_streamable")]
pub streamable: bool,
}
fn default_streamable() -> bool {
true // Default to true for backwards compatibility with existing queue data
} }
/// Repeat mode options /// Repeat mode options
+7
View File
@@ -3,6 +3,10 @@ use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
fn default_streamable() -> bool {
true
}
/// Represents a track in the persisted queue /// Represents a track in the persisted queue
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedQueueTrack { pub struct PersistedQueueTrack {
@@ -20,6 +24,8 @@ pub struct PersistedQueueTrack {
pub is_local: bool, pub is_local: bool,
pub album_id: Option<String>, pub album_id: Option<String>,
pub artist_id: Option<u64>, pub artist_id: Option<u64>,
#[serde(default = "default_streamable")]
pub streamable: bool,
} }
/// Represents the full persisted session state /// Represents the full persisted session state
@@ -261,6 +267,7 @@ impl SessionStore {
is_local: row.get::<_, i64>(9).unwrap_or(0) != 0, is_local: row.get::<_, i64>(9).unwrap_or(0) != 0,
album_id: row.get(10)?, album_id: row.get(10)?,
artist_id: row.get::<_, Option<i64>>(11)?.map(|v| v as u64), artist_id: row.get::<_, Option<i64>>(11)?.map(|v| v as u64),
streamable: true, // Default to true for persisted tracks
}) })
}) })
.map_err(|e| format!("Failed to query queue tracks: {}", e))? .map_err(|e| format!("Failed to query queue tracks: {}", e))?
+2 -1
View File
@@ -346,7 +346,8 @@ export function convertQobuzAlbum(album: QobuzAlbum): AlbumDetail {
samplingRate: track.maximum_sampling_rate, samplingRate: track.maximum_sampling_rate,
albumId: album.id, albumId: album.id,
artistId: track.performer?.id ?? album.artist?.id, artistId: track.performer?.id ?? album.artist?.id,
isrc: track.isrc isrc: track.isrc,
streamable: track.streamable ?? true
})) || [], })) || [],
upc: album.upc upc: album.upc
}; };
+24 -4
View File
@@ -27,6 +27,12 @@ function showToast(message: string, type: ToastType): void {
export function buildQueueTrackFromQobuz(track: QobuzTrack): BackendQueueTrack { export function buildQueueTrackFromQobuz(track: QobuzTrack): BackendQueueTrack {
const artwork = track.album?.image?.large || track.album?.image?.thumbnail || track.album?.image?.small || ''; const artwork = track.album?.image?.large || track.album?.image?.thumbnail || track.album?.image?.small || '';
// Log if track is not streamable (for debugging unavailable tracks)
if (track.streamable === false) {
console.warn(`[Track] Non-streamable track: "${track.title}" by ${track.performer?.name} (ID: ${track.id})`);
}
return { return {
id: track.id, id: track.id,
title: track.title, title: track.title,
@@ -39,7 +45,8 @@ export function buildQueueTrackFromQobuz(track: QobuzTrack): BackendQueueTrack {
sample_rate: track.maximum_sampling_rate ?? null, sample_rate: track.maximum_sampling_rate ?? null,
is_local: false, is_local: false,
album_id: track.album?.id || null, album_id: track.album?.id || null,
artist_id: track.performer?.id ?? null artist_id: track.performer?.id ?? null,
streamable: track.streamable ?? true
}; };
} }
@@ -51,6 +58,11 @@ export function buildQueueTrackFromAlbumTrack(
albumId?: string, albumId?: string,
artistId?: number artistId?: number
): BackendQueueTrack { ): BackendQueueTrack {
// Log if track is not streamable
if (track.streamable === false) {
console.warn(`[Track] Non-streamable track: "${track.title}" (ID: ${track.id})`);
}
return { return {
id: track.id, id: track.id,
title: track.title, title: track.title,
@@ -63,11 +75,17 @@ export function buildQueueTrackFromAlbumTrack(
sample_rate: track.samplingRate ?? null, sample_rate: track.samplingRate ?? null,
is_local: false, is_local: false,
album_id: track.albumId || albumId || null, album_id: track.albumId || albumId || null,
artist_id: track.artistId ?? artistId ?? null artist_id: track.artistId ?? artistId ?? null,
streamable: track.streamable ?? true
}; };
} }
export function buildQueueTrackFromPlaylistTrack(track: PlaylistTrack): BackendQueueTrack { export function buildQueueTrackFromPlaylistTrack(track: PlaylistTrack): BackendQueueTrack {
// Log if track is not streamable
if (track.streamable === false) {
console.warn(`[Track] Non-streamable playlist track: "${track.title}" (ID: ${track.id})`);
}
return { return {
id: track.id, id: track.id,
title: track.title, title: track.title,
@@ -80,7 +98,8 @@ export function buildQueueTrackFromPlaylistTrack(track: PlaylistTrack): BackendQ
sample_rate: track.samplingRate ?? null, sample_rate: track.samplingRate ?? null,
is_local: false, is_local: false,
album_id: track.albumId || null, album_id: track.albumId || null,
artist_id: track.artistId ?? null artist_id: track.artistId ?? null,
streamable: track.streamable ?? true
}; };
} }
@@ -100,7 +119,8 @@ export function buildQueueTrackFromLocalTrack(track: LocalLibraryTrack): Backend
sample_rate: track.sample_rate ?? null, sample_rate: track.sample_rate ?? null,
is_local: true, is_local: true,
album_id: null, // Local tracks don't have Qobuz album IDs album_id: null, // Local tracks don't have Qobuz album IDs
artist_id: null // Local tracks don't have Qobuz artist IDs artist_id: null, // Local tracks don't have Qobuz artist IDs
streamable: true // Local tracks are always playable
}; };
} }
+2
View File
@@ -30,6 +30,8 @@ export interface BackendQueueTrack {
is_local?: boolean; is_local?: boolean;
album_id?: string | null; album_id?: string | null;
artist_id?: number | null; artist_id?: number | null;
/** Whether the track is streamable on Qobuz (false = removed/unavailable) */
streamable?: boolean;
} }
interface BackendQueueState { interface BackendQueueState {
+6
View File
@@ -26,6 +26,8 @@ export interface QobuzTrack {
}; };
performer?: { id?: number; name: string }; performer?: { id?: number; name: string };
hires_streamable?: boolean; hires_streamable?: boolean;
/** Whether the track is streamable (false = removed/unavailable on Qobuz) */
streamable?: boolean;
maximum_bit_depth?: number; maximum_bit_depth?: number;
maximum_sampling_rate?: number; maximum_sampling_rate?: number;
isrc?: string; isrc?: string;
@@ -153,6 +155,8 @@ export interface Track {
albumId?: string; albumId?: string;
artistId?: number; artistId?: number;
isrc?: string; isrc?: string;
/** Whether the track is streamable (false = unavailable on Qobuz) */
streamable?: boolean;
} }
export interface AlbumDetail { export interface AlbumDetail {
@@ -277,6 +281,8 @@ export interface PlaylistTrack {
albumId?: string; albumId?: string;
artistId?: number; artistId?: number;
isrc?: string; isrc?: string;
/** Whether the track is streamable (false = unavailable on Qobuz) */
streamable?: boolean;
} }
/** /**
+22
View File
@@ -1244,6 +1244,28 @@
async function playQueueTrack(track: BackendQueueTrack, skippedIds = new Set<number>()) { async function playQueueTrack(track: BackendQueueTrack, skippedIds = new Set<number>()) {
const isLocal = isLocalTrack(track.id); const isLocal = isLocalTrack(track.id);
// DEBUG: Log track metadata from Qobuz
console.log('[DEBUG] Track metadata:', {
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
duration_secs: track.duration_secs,
bit_depth: track.bit_depth,
sample_rate: track.sample_rate,
hires: track.hires,
artwork_url: track.artwork_url,
album_id: track.album_id,
artist_id: track.artist_id,
streamable: track.streamable,
isLocal
});
// Warn if trying to play a non-streamable track
if (track.streamable === false && !isLocal) {
console.warn(`[Player] Attempting to play non-streamable track: "${track.title}" (ID: ${track.id})`);
}
// In offline mode, check if track is available // In offline mode, check if track is available
if (offlineStatus.isOffline && !isLocal) { if (offlineStatus.isOffline && !isLocal) {
const available = await isTrackAvailable(track); const available = await isTrackAvailable(track);