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:
11 files changed
+231
-8
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user