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 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 {
id: track.id,
title: track.title.clone(),
@@ -281,6 +289,7 @@ fn track_to_queue_track(track: &Track) -> QueueTrack {
is_local: false,
album_id,
artist_id,
streamable: track.streamable,
}
}
+7
View File
@@ -30,6 +30,13 @@ pub struct QueueTrack {
pub album_id: Option<String>,
/// Artist ID for navigation (Qobuz artist ID)
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
+7
View File
@@ -3,6 +3,10 @@ use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
fn default_streamable() -> bool {
true
}
/// Represents a track in the persisted queue
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedQueueTrack {
@@ -20,6 +24,8 @@ pub struct PersistedQueueTrack {
pub is_local: bool,
pub album_id: Option<String>,
pub artist_id: Option<u64>,
#[serde(default = "default_streamable")]
pub streamable: bool,
}
/// Represents the full persisted session state
@@ -261,6 +267,7 @@ impl SessionStore {
is_local: row.get::<_, i64>(9).unwrap_or(0) != 0,
album_id: row.get(10)?,
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))?
+2 -1
View File
@@ -346,7 +346,8 @@ export function convertQobuzAlbum(album: QobuzAlbum): AlbumDetail {
samplingRate: track.maximum_sampling_rate,
albumId: album.id,
artistId: track.performer?.id ?? album.artist?.id,
isrc: track.isrc
isrc: track.isrc,
streamable: track.streamable ?? true
})) || [],
upc: album.upc
};
+24 -4
View File
@@ -27,6 +27,12 @@ function showToast(message: string, type: ToastType): void {
export function buildQueueTrackFromQobuz(track: QobuzTrack): BackendQueueTrack {
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 {
id: track.id,
title: track.title,
@@ -39,7 +45,8 @@ export function buildQueueTrackFromQobuz(track: QobuzTrack): BackendQueueTrack {
sample_rate: track.maximum_sampling_rate ?? null,
is_local: false,
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,
artistId?: number
): BackendQueueTrack {
// Log if track is not streamable
if (track.streamable === false) {
console.warn(`[Track] Non-streamable track: "${track.title}" (ID: ${track.id})`);
}
return {
id: track.id,
title: track.title,
@@ -63,11 +75,17 @@ export function buildQueueTrackFromAlbumTrack(
sample_rate: track.samplingRate ?? null,
is_local: false,
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 {
// Log if track is not streamable
if (track.streamable === false) {
console.warn(`[Track] Non-streamable playlist track: "${track.title}" (ID: ${track.id})`);
}
return {
id: track.id,
title: track.title,
@@ -80,7 +98,8 @@ export function buildQueueTrackFromPlaylistTrack(track: PlaylistTrack): BackendQ
sample_rate: track.samplingRate ?? null,
is_local: false,
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,
is_local: true,
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;
album_id?: string | null;
artist_id?: number | null;
/** Whether the track is streamable on Qobuz (false = removed/unavailable) */
streamable?: boolean;
}
interface BackendQueueState {
+6
View File
@@ -26,6 +26,8 @@ export interface QobuzTrack {
};
performer?: { id?: number; name: string };
hires_streamable?: boolean;
/** Whether the track is streamable (false = removed/unavailable on Qobuz) */
streamable?: boolean;
maximum_bit_depth?: number;
maximum_sampling_rate?: number;
isrc?: string;
@@ -153,6 +155,8 @@ export interface Track {
albumId?: string;
artistId?: number;
isrc?: string;
/** Whether the track is streamable (false = unavailable on Qobuz) */
streamable?: boolean;
}
export interface AlbumDetail {
@@ -277,6 +281,8 @@ export interface PlaylistTrack {
albumId?: string;
artistId?: number;
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>()) {
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
if (offlineStatus.isOffline && !isLocal) {
const available = await isTrackAvailable(track);