From e68c3128b6d40471bec776863d9a89be7a85efb1 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:10:26 -0600 Subject: [PATCH 1/8] feat: auto-skip unavailable tracks with user-friendly error Backend: - Add TrackUnavailable error type for tracks removed from Qobuz - Validate stream URL response - detect missing URL with restrictions - Add SampleRestrictedByRightHolders to restriction check - Return specific error when track is unavailable vs quality unavailable Frontend: - Detect track unavailable errors in playTrack() - Show friendly toast: Track title is no longer available - Auto-skip to next track in queue after 500ms delay - Add i18n translations (en/es) for trackUnavailable message --- src-tauri/src/api/client.rs | 24 ++++++++++++++++++++- src-tauri/src/api/error.rs | 3 +++ src-tauri/src/api/models.rs | 2 ++ src/lib/i18n/locales/en.json | 1 + src/lib/i18n/locales/es.json | 1 + src/lib/services/playbackService.ts | 33 ++++++++++++++++++++++++++++- 6 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/api/client.rs b/src-tauri/src/api/client.rs index a48c6977e..b91728476 100644 --- a/src-tauri/src/api/client.rs +++ b/src-tauri/src/api/client.rs @@ -589,8 +589,17 @@ impl QobuzClient { .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); + // Validate that we got an actual URL (track may be unavailable) + let url = json["url"].as_str().unwrap_or("").to_string(); + if url.is_empty() { + // Log the restriction codes for debugging + let restriction_codes: Vec<&str> = restrictions.iter().map(|r| r.code.as_str()).collect(); + log::warn!("Stream URL missing for track {} - restrictions: {:?}", track_id, restriction_codes); + return Err(ApiError::TrackUnavailable(track_id)); + } + Ok(StreamUrl { - url: json["url"].as_str().unwrap_or("").to_string(), + url, format_id: json["format_id"].as_u64().unwrap_or(0) as u32, mime_type: json["mime_type"].as_str().unwrap_or("").to_string(), sampling_rate: json["sampling_rate"].as_f64().unwrap_or(0.0), @@ -614,6 +623,8 @@ impl QobuzClient { let qualities = Quality::fallback_order(); let start_idx = qualities.iter().position(|q| *q == preferred).unwrap_or(0); + let mut track_unavailable = false; + for quality in &qualities[start_idx..] { log::info!("Trying quality: {:?}", quality); match self.get_stream_url(track_id, *quality).await { @@ -629,6 +640,11 @@ impl QobuzClient { log::error!("Invalid app secret"); return Err(ApiError::InvalidAppSecret); }, + Err(ApiError::TrackUnavailable(_)) => { + // Track is completely unavailable on Qobuz + track_unavailable = true; + continue; + }, Err(e) => { log::warn!("Quality {:?} failed: {}, trying next", quality, e); continue; @@ -636,6 +652,12 @@ impl QobuzClient { } } + // If all quality levels reported track unavailable, return that specific error + if track_unavailable { + log::error!("Track {} is no longer available on Qobuz", track_id); + return Err(ApiError::TrackUnavailable(track_id)); + } + log::error!("No quality available for track {}", track_id); Err(ApiError::NoQualityAvailable) } diff --git a/src-tauri/src/api/error.rs b/src-tauri/src/api/error.rs index 18f0cea8b..c11a40368 100644 --- a/src-tauri/src/api/error.rs +++ b/src-tauri/src/api/error.rs @@ -28,6 +28,9 @@ pub enum ApiError { #[error("No valid quality available for this track")] NoQualityAvailable, + #[error("Track {0} is no longer available on Qobuz")] + TrackUnavailable(u64), + #[error("Network error: {0}")] NetworkError(#[from] reqwest::Error), diff --git a/src-tauri/src/api/models.rs b/src-tauri/src/api/models.rs index 209926b68..8ee36530f 100644 --- a/src-tauri/src/api/models.rs +++ b/src-tauri/src/api/models.rs @@ -67,9 +67,11 @@ pub struct StreamUrl { } impl StreamUrl { + /// Check if the stream has restrictions that prevent playback pub fn has_restrictions(&self) -> bool { self.restrictions.iter().any(|r| { r.code == "FormatRestrictedByFormatAvailability" + || r.code == "SampleRestrictedByRightHolders" }) } } diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index fa94f5d04..f15c906af 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -253,6 +253,7 @@ "lyrics": "Lyrics", "noLyrics": "No lyrics available", "fetchingLyrics": "Fetching lyrics...", + "trackUnavailable": "is no longer available", "shuffle": "Shuffle", "repeat": "Repeat", "repeatAll": "Repeat All", diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json index e38db60e6..0499437cb 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -253,6 +253,7 @@ "lyrics": "Letras", "noLyrics": "No hay letras disponibles", "fetchingLyrics": "Buscando letras...", + "trackUnavailable": "ya no está disponible", "shuffle": "Aleatorio", "repeat": "Repetir", "repeatAll": "Repetir Todo", diff --git a/src/lib/services/playbackService.ts b/src/lib/services/playbackService.ts index 6e5daea03..6f22663a8 100644 --- a/src/lib/services/playbackService.ts +++ b/src/lib/services/playbackService.ts @@ -25,7 +25,7 @@ import { setIsFavorite, type PlayingTrack } from '$lib/stores/playerStore'; -import { syncQueueState } from '$lib/stores/queueStore'; +import { syncQueueState, nextTrack } from '$lib/stores/queueStore'; import { logRecoEvent } from '$lib/services/recoService'; import { isCasting, @@ -199,6 +199,37 @@ export async function playTrack( } catch (err) { console.error('Failed to play track:', err); dismissBuffering(); + + // Check if track is unavailable on Qobuz + const errorStr = String(err); + if (errorStr.includes('no longer available') || errorStr.includes('TrackUnavailable')) { + showToast(`"${track.title}" is no longer available`, 'error'); + + // Auto-skip to next track + const next = await nextTrack(); + if (next) { + console.log('Auto-skipping to next track:', next.title); + // Small delay to let the toast be visible + setTimeout(() => { + playTrack({ + id: next.id, + title: next.title, + artist: next.artist, + album: next.album, + duration: next.duration_secs, + artwork: next.artwork_url || undefined, + albumId: next.album_id || undefined, + artistId: next.artist_id || undefined, + bitDepth: next.bit_depth || undefined, + samplingRate: next.sample_rate || undefined + }, { showLoadingToast: true, showSuccessToast: true }); + }, 500); + } else { + setIsPlaying(false); + } + return false; + } + showToast(`Playback error: ${err}`, 'error'); setIsPlaying(false); return false; From 503e86eeb429758a50248b0f2bd9c11ae6243b7e Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:15:04 -0600 Subject: [PATCH 2/8] feat: gray out unavailable tracks with info icon in playlist view - Add streamable field to PlaylistTrack and DisplayTrack interfaces - Add isTrackRemovedFromQobuz() check for tracks with streamable: false - Show red AlertCircle icon for unavailable tracks - Gray out and apply grayscale filter to unavailable track rows - Limit menu actions to only 'Remove from Playlist' for removed tracks - Hide favorite and download buttons for removed tracks - Keep wrapper interactive for context menu access --- .../views/PlaylistDetailView.svelte | 85 +++++++++++++++++-- 1 file changed, 77 insertions(+), 8 deletions(-) diff --git a/src/lib/components/views/PlaylistDetailView.svelte b/src/lib/components/views/PlaylistDetailView.svelte index 0795c30b6..afb4596f3 100644 --- a/src/lib/components/views/PlaylistDetailView.svelte +++ b/src/lib/components/views/PlaylistDetailView.svelte @@ -1,5 +1,5 @@ + +{#if isOpen} + +{/if} + + diff --git a/src/lib/components/views/PlaylistDetailView.svelte b/src/lib/components/views/PlaylistDetailView.svelte index c5bd9cebb..4e14f9e01 100644 --- a/src/lib/components/views/PlaylistDetailView.svelte +++ b/src/lib/components/views/PlaylistDetailView.svelte @@ -3,6 +3,7 @@ import AlbumMenu from '../AlbumMenu.svelte'; import PlaylistCollage from '../PlaylistCollage.svelte'; import PlaylistModal from '../PlaylistModal.svelte'; + import TrackReplacementModal from '../TrackReplacementModal.svelte'; import ViewTransition from '../ViewTransition.svelte'; import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { invoke } from '@tauri-apps/api/core'; @@ -17,7 +18,8 @@ type OfflineStatus } from '$lib/stores/offlineStore'; import { consumeContextTrackFocus, setPlaybackContext } from '$lib/stores/playbackContextStore'; - import { isTrackUnavailable, subscribe as subscribeUnavailable } from '$lib/stores/unavailableTracksStore'; + import { isTrackUnavailable, clearTrackUnavailable, subscribe as subscribeUnavailable } from '$lib/stores/unavailableTracksStore'; + import { showToast } from '$lib/stores/toastStore'; import { t } from '$lib/i18n'; import { onMount, tick } from 'svelte'; @@ -250,6 +252,10 @@ let isCopying = $state(false); let isCopied = $state(false); + // Track replacement modal state + let replacementModalOpen = $state(false); + let trackToReplace = $state(null); + // Track copied playlists in localStorage const COPIED_PLAYLISTS_KEY = 'qbz_copied_playlists'; @@ -1235,6 +1241,95 @@ } } + // Open replacement modal for an unavailable track + function openReplacementModal(track: DisplayTrack) { + trackToReplace = track; + replacementModalOpen = true; + } + + // Handle track replacement selection + interface ReplacementTrack { + id: number; + title: string; + duration: number; + performer?: { id?: number; name: string }; + album?: { + id: string; + title: string; + image?: { small?: string; thumbnail?: string; large?: string }; + }; + hires: boolean; + maximum_bit_depth?: number; + maximum_sampling_rate?: number; + } + + async function handleTrackReplacement(newTrack: ReplacementTrack) { + if (!trackToReplace || !trackToReplace.playlistTrackId) { + console.error('No track to replace or missing playlist_track_id'); + return; + } + + try { + // Get the current position of the track being replaced + const currentIndex = displayTracks.findIndex(t => t.id === trackToReplace!.id); + + // Remove the old track + await invoke('remove_tracks_from_playlist', { + playlistId, + playlistTrackIds: [trackToReplace.playlistTrackId] + }); + + // Add the new track + await invoke('add_tracks_to_playlist', { + playlistId, + trackIds: [newTrack.id] + }); + + // Clear the unavailable status for the old track (if it was in the store) + clearTrackUnavailable(trackToReplace.id); + + // Reload the playlist to get updated data + await loadPlaylist(); + notifyParentOfCounts(); + onPlaylistUpdated?.(); + + // Show success message + showToast($t('playlist.trackReplaced'), 'success'); + + // Close modal + replacementModalOpen = false; + trackToReplace = null; + + console.log(`[Playlist] Track replaced: ${trackToReplace?.title} -> ${newTrack.title} at position ${currentIndex}`); + } catch (err) { + console.error('Failed to replace track:', err); + showToast($t('playlist.trackReplaceFailed'), 'error'); + } + } + + // Preview a replacement track + function handlePreviewReplacement(track: ReplacementTrack) { + if (!onTrackPlay) return; + + const displayTrack: DisplayTrack = { + id: track.id, + number: 0, + title: track.title, + artist: track.performer?.name, + album: track.album?.title, + albumArt: track.album?.image?.large || track.album?.image?.thumbnail, + albumId: track.album?.id, + artistId: track.performer?.id, + duration: formatDuration(track.duration), + durationSeconds: track.duration, + hires: track.hires, + bitDepth: track.maximum_bit_depth, + samplingRate: track.maximum_sampling_rate + }; + + onTrackPlay(displayTrack); + } + // Add a suggested track to the playlist async function handleAddSuggestedTrack(suggestedTrack: import('$lib/services/playlistSuggestionsService').SuggestedTrack) { try { @@ -1738,8 +1833,9 @@ onDownload={available && !track.isLocal && onTrackDownload ? () => onTrackDownload(track) : undefined} onRemoveDownload={available && !track.isLocal && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined} menuActions={removedFromQobuz ? (isOwnPlaylist ? { - // Only allow remove from playlist for tracks removed from Qobuz (owned playlists only) - onRemoveFromPlaylist: () => removeTrackFromPlaylist(track) + // Only allow remove from playlist and find replacement for tracks removed from Qobuz (owned playlists only) + onRemoveFromPlaylist: () => removeTrackFromPlaylist(track), + onFindReplacement: () => openReplacementModal(track) } : {}) : available ? { onPlayNow: () => handleTrackClick(track, idx), onPlayNext: track.isLocal ? () => handleTrackPlayNext(track) : (onTrackPlayNext ? () => onTrackPlayNext(track) : undefined), @@ -1800,6 +1896,16 @@ /> {/if} + + { replacementModalOpen = false; trackToReplace = null; }} + onSelect={handleTrackReplacement} + onPreview={handlePreviewReplacement} +/> +