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(
|
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]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user