fix(offline): real offline mode — snapshot streaming, block network, diag logs (#279)

Addresses the reported behavior where tracks marked available offline
still buffered from Qobuz after the v1.2.2 upgrade. Three layers:

1. **Snapshot / restore stream-first-track.** offline_settings gains a
   new nullable column pre_offline_stream_first_track (SQLite ALTER
   migration). v2_set_manual_offline now coordinates with
   AudioSettingsState: entering offline mode stashes the user's current
   stream_first_track preference and forces it to false; exiting
   restores the snapshot. User's preference survives the round-trip
   even across app restarts.

2. **Explicit offline refusal.** v2_play_track now takes
   OfflineState, reads manual_offline_mode up front, and — when every
   local cache miss has been exhausted — returns a new
   RuntimeError::TrackNotAvailableOffline instead of silently reaching
   for the network. Low-quality offline hits still play (better than
   nothing). The playback path also masks stream_first_enabled while
   offline as a belt-and-suspenders defense.

3. **Cache-miss visibility.** Every call site that consults the
   offline cache DB now logs [V2/CACHE STALE] at warn level when the
   DB says a track is cached but the filesystem path is missing. This
   made the issue #279 behavior invisible; the orphan entries from
   per-user isolation migration (aab3e6764) are now diagnosable from
   logs.

Frontend:
- playbackService surfaces TrackNotAvailableOffline as a toast ('…
  isn't saved for offline playback') instead of the generic error
- SettingsView listens for a new audio-settings-changed event so the
  stream-first-track toggle in the UI flips when the offline toggle
  mutates it
- i18n key offline.trackNotAvailableOffline added to all 5 locales

cc
This commit is contained in:
vicrodh committed 2026-04-13 22:13:16 -06:00
1 parent 722696df34
commit 808b9db0db
11 files changed
+231 -8

No files matched your search

+2 -1
View File
@@ -929,9 +929,10 @@ pub fn v2_get_offline_settings(
pub async fn v2_set_manual_offline( pub async fn v2_set_manual_offline(
enabled: bool, enabled: bool,
state: State<'_, OfflineState>, state: State<'_, OfflineState>,
audio_state: State<'_, crate::config::audio_settings::AudioSettingsState>,
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
) -> Result<crate::offline::OfflineStatus, String> { ) -> Result<crate::offline::OfflineStatus, String> {
crate::offline::commands::set_manual_offline(enabled, state, app_handle).await crate::offline::commands::set_manual_offline(enabled, state, audio_state, app_handle).await
} }
#[tauri::command] #[tauri::command]
+64 -2
View File
@@ -447,6 +447,13 @@ pub async fn v2_play_next_gapless(
}; };
if let Some(file_path) = cached_path { if let Some(file_path) = cached_path {
let path = std::path::Path::new(&file_path); let path = std::path::Path::new(&file_path);
if !path.exists() {
log::warn!(
"[V2/GAPLESS/CACHE STALE] Track {} DB entry points to missing file {:?}",
track_id,
path
);
}
if path.exists() { if path.exists() {
log::info!("[V2/GAPLESS] Track {} from OFFLINE cache", track_id); log::info!("[V2/GAPLESS] Track {} from OFFLINE cache", track_id);
let audio_data = std::fs::read(path).map_err(|e| { let audio_data = std::fs::read(path).map_err(|e| {
@@ -589,6 +596,13 @@ pub async fn v2_prefetch_track(
}; };
if let Some(file_path) = cached_path { if let Some(file_path) = cached_path {
let path = std::path::Path::new(&file_path); let path = std::path::Path::new(&file_path);
if !path.exists() {
log::warn!(
"[V2/PREFETCH/CACHE STALE] Track {} DB entry points to missing file {:?}",
track_id,
path
);
}
if path.exists() { if path.exists() {
log::info!("[V2] Prefetching track {} from offline cache", track_id); log::info!("[V2] Prefetching track {} from offline cache", track_id);
let audio_data = std::fs::read(path).map_err(|e| { let audio_data = std::fs::read(path).map_err(|e| {
@@ -658,6 +672,7 @@ pub async fn v2_play_track(
bridge: State<'_, CoreBridgeState>, bridge: State<'_, CoreBridgeState>,
offline_cache: State<'_, OfflineCacheState>, offline_cache: State<'_, OfflineCacheState>,
audio_settings: State<'_, AudioSettingsState>, audio_settings: State<'_, AudioSettingsState>,
offline_state: State<'_, crate::offline::OfflineState>,
app_state: State<'_, AppState>, app_state: State<'_, AppState>,
runtime: State<'_, RuntimeManagerState>, runtime: State<'_, RuntimeManagerState>,
) -> Result<V2PlayTrackResult, RuntimeError> { ) -> Result<V2PlayTrackResult, RuntimeError> {
@@ -705,14 +720,34 @@ pub async fn v2_play_track(
final_quality final_quality
}; };
// Check streaming settings // Manual offline mode — read once up front so the cache-miss branch
// below knows whether to fail loudly instead of silently reaching for
// the network (issue #279).
let manual_offline_mode = {
let guard = offline_state
.store
.lock()
.map_err(|e| RuntimeError::Internal(format!("Offline state lock error: {}", e)))?;
guard
.as_ref()
.and_then(|s| s.get_settings().ok())
.map(|s| s.manual_offline_mode)
.unwrap_or(false)
};
// Check streaming settings. In manual offline mode we also force
// stream-first off at read time in case the set_manual_offline command
// raced with a fresh settings write.
let (stream_first_enabled, streaming_only) = { let (stream_first_enabled, streaming_only) = {
let guard = audio_settings let guard = audio_settings
.store .store
.lock() .lock()
.map_err(|e| RuntimeError::Internal(format!("Lock error: {}", e)))?; .map_err(|e| RuntimeError::Internal(format!("Lock error: {}", e)))?;
match guard.as_ref().and_then(|s| s.get_settings().ok()) { match guard.as_ref().and_then(|s| s.get_settings().ok()) {
Some(s) => (s.stream_first_track, s.streaming_only), Some(s) => {
let stream_first = s.stream_first_track && !manual_offline_mode;
(stream_first, s.streaming_only)
}
None => (false, false), None => (false, false),
} }
}; };
@@ -773,6 +808,16 @@ pub async fn v2_play_track(
}; };
if let Some(file_path) = cached_path { if let Some(file_path) = cached_path {
let path = std::path::Path::new(&file_path); let path = std::path::Path::new(&file_path);
if !path.exists() {
// DB said cached, disk says no. Without this log the bug is
// invisible — the app silently reaches for the network. See
// issue #279.
log::warn!(
"[V2/CACHE STALE] Track {} DB entry points to missing file {:?} — entry is orphaned (filesystem moved/unmounted/cleaned?)",
track_id,
path
);
}
if path.exists() { if path.exists() {
log::info!( log::info!(
"[V2/CACHE HIT] Track {} from OFFLINE cache: {:?}", "[V2/CACHE HIT] Track {} from OFFLINE cache: {:?}",
@@ -924,6 +969,23 @@ pub async fn v2_play_track(
); );
} }
// Offline guard: if the user has manual offline mode enabled and we got
// this far (meaning no cache had a usable hit), refuse to reach the
// network. Returning an explicit error lets the frontend show a clear
// "not available offline" toast instead of streaming invisibly — that
// silent fallback was the root of issue #279.
//
// If we have a low-quality fallback in memory we play that instead; the
// user at least hears something, which is better than nothing, and a
// partial match is a real offline cache hit (just at a degraded tier).
if manual_offline_mode && low_quality_fallback.is_none() {
log::warn!(
"[V2/OFFLINE] Track {} not in any cache and manual offline mode is ON — refusing network fetch",
track_id
);
return Err(RuntimeError::TrackNotAvailableOffline);
}
// Try CMAF streaming pipeline (Akamai CDN, encrypted segments) // Try CMAF streaming pipeline (Akamai CDN, encrypted segments)
// Only the init segment is fetched synchronously; audio segments stream in background. // Only the init segment is fetched synchronously; audio segments stream in background.
log::info!("[V2/CMAF] Attempting CMAF streaming for track {}", track_id); log::info!("[V2/CMAF] Attempting CMAF streaming for track {}", track_id);
+124 -4
View File
@@ -149,6 +149,11 @@ impl OfflineStore {
"ALTER TABLE offline_settings ADD COLUMN allow_immediate_scrobbling INTEGER NOT NULL DEFAULT 0", "ALTER TABLE offline_settings ADD COLUMN allow_immediate_scrobbling INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE offline_settings ADD COLUMN allow_accumulated_scrobbling INTEGER NOT NULL DEFAULT 1", "ALTER TABLE offline_settings ADD COLUMN allow_accumulated_scrobbling INTEGER NOT NULL DEFAULT 1",
"ALTER TABLE offline_settings ADD COLUMN show_network_folders_in_manual_offline INTEGER NOT NULL DEFAULT 0", "ALTER TABLE offline_settings ADD COLUMN show_network_folders_in_manual_offline INTEGER NOT NULL DEFAULT 0",
// Snapshot of audio_settings.stream_first_track taken when manual
// offline mode is enabled; used to restore the user's preference
// when they exit offline mode. NULL means "no snapshot active".
// See issue #279.
"ALTER TABLE offline_settings ADD COLUMN pre_offline_stream_first_track INTEGER",
"ALTER TABLE pending_playlist_sync ADD COLUMN local_track_ids TEXT", "ALTER TABLE pending_playlist_sync ADD COLUMN local_track_ids TEXT",
"ALTER TABLE pending_playlist_sync ADD COLUMN local_track_paths TEXT", "ALTER TABLE pending_playlist_sync ADD COLUMN local_track_paths TEXT",
]; ];
@@ -190,6 +195,35 @@ impl OfflineStore {
Ok(()) Ok(())
} }
/// Read the saved stream-first-track preference stashed when the user
/// entered offline mode. Returns None when no snapshot is active.
pub fn get_pre_offline_stream_first_track(&self) -> Result<Option<bool>, String> {
self.conn
.query_row(
"SELECT pre_offline_stream_first_track FROM offline_settings WHERE id = 1",
[],
|row| row.get::<_, Option<i64>>(0),
)
.map(|opt| opt.map(|v| v != 0))
.map_err(|e| format!("Failed to read pre_offline_stream_first_track: {}", e))
}
/// Store or clear the pre-offline snapshot. Pass `Some(value)` when
/// entering offline mode, `None` when exiting.
pub fn set_pre_offline_stream_first_track(
&self,
value: Option<bool>,
) -> Result<(), String> {
let param: Option<i64> = value.map(|v| v as i64);
self.conn
.execute(
"UPDATE offline_settings SET pre_offline_stream_first_track = ?1 WHERE id = 1",
params![param],
)
.map_err(|e| format!("Failed to set pre_offline_stream_first_track: {}", e))?;
Ok(())
}
pub fn set_show_partial_playlists(&self, enabled: bool) -> Result<(), String> { pub fn set_show_partial_playlists(&self, enabled: bool) -> Result<(), String> {
self.conn self.conn
.execute( .execute(
@@ -700,14 +734,22 @@ pub mod commands {
store.get_settings() store.get_settings()
} }
/// Enable or disable manual offline mode /// Enable or disable manual offline mode.
///
/// When entering offline mode, snapshots the user's current
/// `stream_first_track` audio setting and forces it to `false` so the
/// player won't attempt a network pre-buffer. When exiting, restores the
/// snapshot. This makes "offline mode" mean what it says — issue #279.
#[tauri::command] #[tauri::command]
pub async fn set_manual_offline( pub async fn set_manual_offline(
enabled: bool, enabled: bool,
state: State<'_, OfflineState>, state: State<'_, OfflineState>,
audio_state: State<'_, crate::config::audio_settings::AudioSettingsState>,
app_handle: AppHandle, app_handle: AppHandle,
) -> Result<OfflineStatus, String> { ) -> Result<OfflineStatus, String> {
{ // 1) Persist the manual offline flag first so any concurrent reads
// see the new mode immediately.
let (was_offline, stream_first_before): (bool, Option<bool>) = {
let guard__ = state let guard__ = state
.store .store
.lock() .lock()
@@ -715,14 +757,92 @@ pub mod commands {
let store = guard__ let store = guard__
.as_ref() .as_ref()
.ok_or("No active session - please log in")?; .ok_or("No active session - please log in")?;
let prev = store
.get_settings()
.map(|s| s.manual_offline_mode)
.unwrap_or(false);
let snapshot = store
.get_pre_offline_stream_first_track()
.unwrap_or(None);
store.set_manual_offline_mode(enabled)?; store.set_manual_offline_mode(enabled)?;
(prev, snapshot)
};
// 2) Coordinate the stream_first_track snapshot/restore. This is best-
// effort — if the audio store isn't initialized yet (pre-login), we
// just skip; the offline flag itself has been persisted.
if enabled && !was_offline {
// Entering offline mode: stash current value and force to false.
let current = {
let guard = audio_state
.store
.lock()
.map_err(|e| format!("Audio settings lock error: {}", e))?;
guard
.as_ref()
.and_then(|s| s.get_settings().ok())
.map(|s| s.stream_first_track)
};
if let Some(current_value) = current {
{
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
if let Some(store) = guard.as_ref() {
let _ = store.set_pre_offline_stream_first_track(Some(current_value));
}
}
if current_value {
let guard = audio_state
.store
.lock()
.map_err(|e| format!("Audio settings lock error: {}", e))?;
if let Some(store) = guard.as_ref() {
let _ = store.set_stream_first_track(false);
log::info!(
"[Offline] stream_first_track snapshot={}; forced to false while offline",
current_value
);
}
}
}
} else if !enabled && was_offline {
// Exiting offline mode: restore snapshot if we have one.
if let Some(snapshot_value) = stream_first_before {
{
let guard = audio_state
.store
.lock()
.map_err(|e| format!("Audio settings lock error: {}", e))?;
if let Some(store) = guard.as_ref() {
let _ = store.set_stream_first_track(snapshot_value);
log::info!(
"[Offline] restored stream_first_track to {}",
snapshot_value
);
}
}
{
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
if let Some(store) = guard.as_ref() {
let _ = store.set_pre_offline_stream_first_track(None);
}
}
}
} }
// Get updated status // 3) Get updated status
let status = get_offline_status(state).await?; let status = get_offline_status(state).await?;
// Emit event to frontend // 4) Emit events. offline-status-changed for the offline store;
// audio-settings-changed so the SettingsView reloads its local
// state of stream_first_track (we may have just mutated it).
let _ = app_handle.emit("offline-status-changed", &status); let _ = app_handle.emit("offline-status-changed", &status);
let _ = app_handle.emit("audio-settings-changed", ());
Ok(status) Ok(status)
} }
+7
View File
@@ -106,6 +106,10 @@ pub enum RuntimeError {
V2AuthFailed(String), V2AuthFailed(String),
/// V2 CoreBridge not initialized /// V2 CoreBridge not initialized
V2NotInitialized, V2NotInitialized,
/// Manual offline mode is on and the requested track is not available
/// in any local cache. Emitted instead of silently streaming from the
/// network (see issue #279).
TrackNotAvailableOffline,
/// Internal error /// Internal error
Internal(String), Internal(String),
} }
@@ -122,6 +126,9 @@ impl std::fmt::Display for RuntimeError {
Self::BootstrapInProgress => write!(f, "Bootstrap already in progress"), Self::BootstrapInProgress => write!(f, "Bootstrap already in progress"),
Self::V2AuthFailed(msg) => write!(f, "V2 authentication failed: {}", msg), Self::V2AuthFailed(msg) => write!(f, "V2 authentication failed: {}", msg),
Self::V2NotInitialized => write!(f, "V2 CoreBridge not initialized"), Self::V2NotInitialized => write!(f, "V2 CoreBridge not initialized"),
Self::TrackNotAvailableOffline => {
write!(f, "Track not available in offline cache while in offline mode")
}
Self::Internal(msg) => write!(f, "Internal error: {}", msg), Self::Internal(msg) => write!(f, "Internal error: {}", msg),
} }
} }
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import ViewTransition from '../ViewTransition.svelte'; import ViewTransition from '../ViewTransition.svelte';
import { getCurrentWebview } from '@tauri-apps/api/webview'; import { getCurrentWebview } from '@tauri-apps/api/webview';
import { writeText as copyToClipboard } from '@tauri-apps/plugin-clipboard-manager'; import { writeText as copyToClipboard } from '@tauri-apps/plugin-clipboard-manager';
@@ -1453,6 +1454,15 @@
loadAlsaPlugins() loadAlsaPlugins()
]).then(() => loadAudioSettings()); ]).then(() => loadAudioSettings());
// Re-sync audio settings when the backend notifies us of a mutation
// outside this view (e.g., set_manual_offline flipping stream_first_track
// per issue #279).
const unlistenAudioSettings = listen('audio-settings-changed', () => {
loadAudioSettings().catch((err) => {
console.warn('[Settings] Failed to reload audio settings after event:', err);
});
});
// Load Last.fm state // Load Last.fm state
loadLastfmState(); loadLastfmState();
@@ -1578,6 +1588,7 @@
if (plexAuthPollTimer) { if (plexAuthPollTimer) {
clearInterval(plexAuthPollTimer); clearInterval(plexAuthPollTimer);
} }
unlistenAudioSettings.then((fn) => fn()).catch(() => {});
unsubscribeOffline(); unsubscribeOffline();
unsubscribeDegraded(); unsubscribeDegraded();
unsubscribeZoom(); unsubscribeZoom();
+1
View File
@@ -1691,6 +1691,7 @@
"someLocal": "Teilweise offline verfügbar", "someLocal": "Teilweise offline verfügbar",
"notAvailable": "Offline nicht verfügbar", "notAvailable": "Offline nicht verfügbar",
"trackNotAvailable": "Dieser Titel ist offline nicht verfügbar", "trackNotAvailable": "Dieser Titel ist offline nicht verfügbar",
"trackNotAvailableOffline": "\"{title}\" ist nicht für die Offline-Wiedergabe gespeichert",
"featureDisabled": "Diese Funktion erfordert eine Internetverbindung", "featureDisabled": "Diese Funktion erfordert eine Internetverbindung",
"startWithoutLogin": "Offline starten (kein Zugriff auf Qobuz™-Dienste)", "startWithoutLogin": "Offline starten (kein Zugriff auf Qobuz™-Dienste)",
"noPreviousSession": "Sie müssen sich mindestens einmal anmelden, bevor Sie den Offline-Modus verwenden können. Stellen Sie eine Internetverbindung her und melden Sie sich an.", "noPreviousSession": "Sie müssen sich mindestens einmal anmelden, bevor Sie den Offline-Modus verwenden können. Stellen Sie eine Internetverbindung her und melden Sie sich an.",
+1
View File
@@ -1714,6 +1714,7 @@
"someLocal": "Partially Available Offline", "someLocal": "Partially Available Offline",
"notAvailable": "Not Available Offline", "notAvailable": "Not Available Offline",
"trackNotAvailable": "This track is not available offline", "trackNotAvailable": "This track is not available offline",
"trackNotAvailableOffline": "\"{title}\" isn't saved for offline playback",
"featureDisabled": "This feature requires internet connection", "featureDisabled": "This feature requires internet connection",
"startWithoutLogin": "Start offline (no access to Qobuz™ services)", "startWithoutLogin": "Start offline (no access to Qobuz™ services)",
"noPreviousSession": "You need to log in at least once before using offline mode. Connect to the internet and log in to create your profile.", "noPreviousSession": "You need to log in at least once before using offline mode. Connect to the internet and log in to create your profile.",
+1
View File
@@ -1714,6 +1714,7 @@
"someLocal": "Parcialmente Disponible Offline", "someLocal": "Parcialmente Disponible Offline",
"notAvailable": "No Disponible Offline", "notAvailable": "No Disponible Offline",
"trackNotAvailable": "Esta pista no esta disponible offline", "trackNotAvailable": "Esta pista no esta disponible offline",
"trackNotAvailableOffline": "\"{title}\" no está guardada para reproducción offline",
"featureDisabled": "Esta funcion requiere conexion a internet", "featureDisabled": "Esta funcion requiere conexion a internet",
"startWithoutLogin": "Iniciar en modo offline (sin acceso a los servicios de Qobuz™)", "startWithoutLogin": "Iniciar en modo offline (sin acceso a los servicios de Qobuz™)",
"noPreviousSession": "Necesitas iniciar sesión al menos una vez antes de usar el modo offline. Conéctate a internet e inicia sesión para crear tu perfil.", "noPreviousSession": "Necesitas iniciar sesión al menos una vez antes de usar el modo offline. Conéctate a internet e inicia sesión para crear tu perfil.",
+1
View File
@@ -1691,6 +1691,7 @@
"someLocal": "Partiellement disponible hors ligne", "someLocal": "Partiellement disponible hors ligne",
"notAvailable": "Non disponible hors ligne", "notAvailable": "Non disponible hors ligne",
"trackNotAvailable": "Ce titre n'est pas disponible hors ligne", "trackNotAvailable": "Ce titre n'est pas disponible hors ligne",
"trackNotAvailableOffline": "\"{title}\" n'est pas enregistré pour la lecture hors ligne",
"featureDisabled": "Cette fonctionnalité nécessite une connexion Internet", "featureDisabled": "Cette fonctionnalité nécessite une connexion Internet",
"startWithoutLogin": "Démarrer hors ligne (pas d'accès aux services Qobuz™)", "startWithoutLogin": "Démarrer hors ligne (pas d'accès aux services Qobuz™)",
"noPreviousSession": "Vous devez vous connecter au moins une fois avant d'utiliser le mode hors ligne. Connectez-vous à Internet et identifiez-vous pour créer votre profil.", "noPreviousSession": "Vous devez vous connecter au moins une fois avant d'utiliser le mode hors ligne. Connectez-vous à Internet et identifiez-vous pour créer votre profil.",
+1
View File
@@ -1714,6 +1714,7 @@
"someLocal": "Parcialmente Disponível Offline", "someLocal": "Parcialmente Disponível Offline",
"notAvailable": "Não Disponível Offline", "notAvailable": "Não Disponível Offline",
"trackNotAvailable": "Esta faixa não está disponível offline", "trackNotAvailable": "Esta faixa não está disponível offline",
"trackNotAvailableOffline": "\"{title}\" não está salva para reprodução offline",
"featureDisabled": "Este recurso requer conexão com a internet", "featureDisabled": "Este recurso requer conexão com a internet",
"startWithoutLogin": "Iniciar offline (sem acesso aos serviços do Qobuz™)", "startWithoutLogin": "Iniciar offline (sem acesso aos serviços do Qobuz™)",
"noPreviousSession": "Você precisa fazer login pelo menos uma vez antes de usar o modo offline. Conecte-se à internet e faça login para criar seu perfil.", "noPreviousSession": "Você precisa fazer login pelo menos uma vez antes de usar o modo offline. Conecte-se à internet e faça login para criar seu perfil.",
+18 -1
View File
@@ -357,8 +357,25 @@ export async function playTrack(
console.error('Failed to play track:', err); console.error('Failed to play track:', err);
dismissBuffering(); dismissBuffering();
// Check if track is unavailable on Qobuz // Check for offline-mode refusal first. The backend returns
// RuntimeError::TrackNotAvailableOffline when manual offline is on and
// the track has no local copy — see issue #279. Tauri serializes typed
// errors with the variant name as `code`; we match either the JSON
// payload or the stringified form depending on how Tauri round-trips it.
const errorStr = String(err); const errorStr = String(err);
const isOfflineUnavailable =
errorStr.includes('TrackNotAvailableOffline') ||
(typeof err === 'object' && err !== null && (err as { code?: string }).code === 'TrackNotAvailableOffline');
if (isOfflineUnavailable) {
const { get } = await import('svelte/store');
const { t } = await import('$lib/i18n');
const msg = get(t)('offline.trackNotAvailableOffline', { values: { title: track.title } });
showToast(msg, 'error');
setIsPlaying(false);
return false;
}
// Check if track is unavailable on Qobuz
if (errorStr.includes('no longer available') || errorStr.includes('TrackUnavailable')) { if (errorStr.includes('no longer available') || errorStr.includes('TrackUnavailable')) {
// Mark track as unavailable for future reference // Mark track as unavailable for future reference
markTrackUnavailable(track.id); markTrackUnavailable(track.id);