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(
enabled: bool,
state: State<'_, OfflineState>,
audio_state: State<'_, crate::config::audio_settings::AudioSettingsState>,
app_handle: tauri::AppHandle,
) -> 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]
+64 -2
View File
@@ -447,6 +447,13 @@ pub async fn v2_play_next_gapless(
};
if let Some(file_path) = cached_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() {
log::info!("[V2/GAPLESS] Track {} from OFFLINE cache", track_id);
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 {
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() {
log::info!("[V2] Prefetching track {} from offline cache", track_id);
let audio_data = std::fs::read(path).map_err(|e| {
@@ -658,6 +672,7 @@ pub async fn v2_play_track(
bridge: State<'_, CoreBridgeState>,
offline_cache: State<'_, OfflineCacheState>,
audio_settings: State<'_, AudioSettingsState>,
offline_state: State<'_, crate::offline::OfflineState>,
app_state: State<'_, AppState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<V2PlayTrackResult, RuntimeError> {
@@ -705,14 +720,34 @@ pub async fn v2_play_track(
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 guard = audio_settings
.store
.lock()
.map_err(|e| RuntimeError::Internal(format!("Lock error: {}", e)))?;
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),
}
};
@@ -773,6 +808,16 @@ pub async fn v2_play_track(
};
if let Some(file_path) = cached_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() {
log::info!(
"[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)
// Only the init segment is fetched synchronously; audio segments stream in background.
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_accumulated_scrobbling INTEGER NOT NULL DEFAULT 1",
"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_paths TEXT",
];
@@ -190,6 +195,35 @@ impl OfflineStore {
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> {
self.conn
.execute(
@@ -700,14 +734,22 @@ pub mod commands {
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]
pub async fn set_manual_offline(
enabled: bool,
state: State<'_, OfflineState>,
audio_state: State<'_, crate::config::audio_settings::AudioSettingsState>,
app_handle: AppHandle,
) -> 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
.store
.lock()
@@ -715,14 +757,92 @@ pub mod commands {
let store = guard__
.as_ref()
.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)?;
(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?;
// 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("audio-settings-changed", ());
Ok(status)
}
+7
View File
@@ -106,6 +106,10 @@ pub enum RuntimeError {
V2AuthFailed(String),
/// V2 CoreBridge not initialized
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(String),
}
@@ -122,6 +126,9 @@ impl std::fmt::Display for RuntimeError {
Self::BootstrapInProgress => write!(f, "Bootstrap already in progress"),
Self::V2AuthFailed(msg) => write!(f, "V2 authentication failed: {}", msg),
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),
}
}