fix(offline-cache): route v2 bundles through CMAF in library + gapless

Two symptoms with the same root cause — code paths that touched the
offline cache read file_path as "a plain FLAC file" and handed it
straight to std::fs::read + Symphonia. For v2 bundles the file_path is
the encrypted segments.bin, so both paths exploded.

1. v2_library_play_track: error "Is a directory (os error 21)" when
   clicking a v2-cached track from Local Library.
   Fix: for source='qobuz_download' rows, the offline-cache DB is the
   authoritative source of file paths, not the library index (library
   carries display metadata only). Query get_cmaf_bundle:
     - cache_format=2 → load_cmaf_bundle (decrypt + play)
     - cache_format=1 → read row.segments_path (which for v1 IS the FLAC)
     - no row → clean error instead of reading the library directory

2. v2_play_next_gapless: "Symphonia probe failed: end of stream" with
   ~6 failed retries per track transition → ~6s gap between two v2
   offline-cached tracks.
   Fix: same cache_format branching as v2_play_track already does.
   Reuse load_cmaf_bundle so the probe sees plain FLAC bytes, not
   encrypted mp4 boxes.

The prefetch AES-CBC unpad error during concurrent downloads is the
other piece of today's gap story — session race in ensure_cmaf_session.
That's a separate fix in the next commit.

cc
This commit is contained in:
vicrodh committed 2026-04-17 09:49:54 -06:00
1 parent 8ab8fc5dea
commit 8238ce4de4
2 files changed
+123 -57

No files matched your search

+63 -28
View File
@@ -496,11 +496,13 @@ pub async fn v2_library_play_track(
.ok_or_else(|| "Track not found".to_string())? .ok_or_else(|| "Track not found".to_string())?
}; };
// Qobuz-cached offline tracks may be stored in the new v2 CMAF format // Qobuz-cached offline tracks are authoritative in the offline-cache
// (cache_format=2), in which case `track.file_path` in the library // DB, not the library index. The library row for these tracks carries
// index points at a directory, not a playable FLAC. Route those // metadata only (title/artist/album) plus a display path; the library's
// through the offline-cache playback helper; it decrypts and hands us // `file_path` for v2 entries is a track directory that is not playable
// plain FLAC bytes identical to what a v1 cached file would yield. // directly. So for source='qobuz_download' we always resolve through
// the offline-cache DB — cache_format tells us whether to decrypt the
// CMAF bundle or to read a plain-FLAC v1 file.
let is_qobuz_cached = track.source.as_deref() == Some("qobuz_download"); let is_qobuz_cached = track.source.as_deref() == Some("qobuz_download");
if is_qobuz_cached { if is_qobuz_cached {
let bundle_row = { let bundle_row = {
@@ -509,42 +511,75 @@ pub async fn v2_library_play_track(
.as_ref() .as_ref()
.and_then(|db| db.get_cmaf_bundle(track_id as u64).ok().flatten()) .and_then(|db| db.get_cmaf_bundle(track_id as u64).ok().flatten())
}; };
if let Some(row) = bundle_row { match bundle_row {
if row.cache_format == 2 { Some(row) if row.cache_format == 2 => {
let cache_path = offline_cache.get_cache_path(); let cache_path = offline_cache.get_cache_path();
if let Some(audio_data) = crate::offline_cache::playback::load_cmaf_bundle( let audio_data = crate::offline_cache::playback::load_cmaf_bundle(
track_id as u64, track_id as u64,
&row, &row,
std::path::Path::new(&cache_path), std::path::Path::new(&cache_path),
) { )
let bridge = bridge.get().await; .ok_or_else(|| {
bridge format!(
.player() "Offline CMAF bundle for track {} is present but failed to decrypt",
.play_data(audio_data, track_id as u64) track_id
.map_err(|e| format!("Failed to play CMAF offline bundle: {}", e))?; )
if let Some(start_secs) = track.cue_start_secs { })?;
let start_pos = start_secs as u64; let bridge = bridge.get().await;
if start_pos > 0 { bridge
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; .player()
bridge .play_data(audio_data, track_id as u64)
.player() .map_err(|e| format!("Failed to play CMAF offline bundle: {}", e))?;
.seek(start_pos) if let Some(start_secs) = track.cue_start_secs {
.map_err(|e| format!("Failed to seek: {}", e))?; let start_pos = start_secs as u64;
} if start_pos > 0 {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
bridge
.player()
.seek(start_pos)
.map_err(|e| format!("Failed to seek: {}", e))?;
} }
return Ok(());
} }
return Ok(());
}
Some(row) => {
// cache_format=1 (legacy plain FLAC). The authoritative path
// is the offline-cache row's `segments_path` field (v1 stored
// the FLAC file path there), not the library index file_path
// (which for qobuz_download entries is now a display-only
// value that may be a directory).
let file_path = std::path::Path::new(&row.segments_path);
if !file_path.exists() {
return Err(format!(
"Offline cache file missing for track {}: {}",
track_id, row.segments_path
));
}
let audio_data = std::fs::read(file_path)
.map_err(|e| format!("Failed to read v1 offline file: {}", e))?;
let bridge = bridge.get().await;
bridge
.player()
.play_data(audio_data, track_id as u64)
.map_err(|e| format!("Failed to play: {}", e))?;
return Ok(());
}
None => {
// Orphan: library row says qobuz_download but offline cache
// has no record. Either the cache was partially wiped or
// there's a corruption — surface the error instead of
// trying to read whatever the library file_path says (which
// for v2 is a directory and blows up with os error 21).
return Err(format!( return Err(format!(
"Offline cache CMAF bundle for track {} is present but failed to decrypt", "Offline cache entry for track {} is missing (library index is stale)",
track_id track_id
)); ));
} }
// cache_format=1 — fall through to the legacy plain-FLAC branch
} }
} }
// Legacy plain-FLAC path: v1 Qobuz-cached entries OR regular user // Regular user local library files (FLAC/MP3 owned by the user,
// local library files. Read the file directly and play. // not Qobuz-cached). Read the file path from the library row.
let file_path = std::path::Path::new(&track.file_path); let file_path = std::path::Path::new(&track.file_path);
if !file_path.exists() { if !file_path.exists() {
return Err(format!("File not found: {}", track.file_path)); return Err(format!("File not found: {}", track.file_path));
+60 -29
View File
@@ -436,38 +436,69 @@ pub async fn v2_play_next_gapless(
return Ok(false); return Ok(false);
} }
// Check offline cache (persistent disk cache) // Check offline cache (persistent disk cache). Branch on cache_format:
// - v2 (CMAF bundle) → decrypt to plain FLAC via load_cmaf_bundle
// - v1 (plain FLAC) → read segments_path (which for v1 entries is
// the actual FLAC file path)
//
// The previous implementation used get_file_path() which blindly
// returned the file_path column, then std::fs::read() on it. For v2
// entries that column is segments.bin (encrypted bytes), so the
// downstream player probe failed with "end of stream" and gapless
// fell apart across v2 offline-cache tracks.
{ {
let cached_path = { let bundle_row = {
let db_opt = offline_cache.db.lock().await; let db_opt = offline_cache.db.lock().await;
if let Some(db) = db_opt.as_ref() { db_opt
if let Ok(Some(file_path)) = db.get_file_path(track_id) { .as_ref()
Some(file_path) .and_then(|db| db.get_cmaf_bundle(track_id).ok().flatten())
} else {
None
}
} else {
None
}
}; };
if let Some(file_path) = cached_path { if let Some(row) = bundle_row {
let path = std::path::Path::new(&file_path); match row.cache_format {
if !path.exists() { 2 => {
log::warn!( let cache_path = offline_cache.get_cache_path();
"[V2/GAPLESS/CACHE STALE] Track {} DB entry points to missing file {:?}", if let Some(audio_data) = crate::offline_cache::playback::load_cmaf_bundle(
track_id, track_id,
path &row,
); std::path::Path::new(&cache_path),
} ) {
if path.exists() { log::info!(
log::info!("[V2/GAPLESS] Track {} from OFFLINE cache", track_id); "[V2/GAPLESS] Track {} from OFFLINE cache (CMAF v2)",
let audio_data = std::fs::read(path).map_err(|e| { track_id
RuntimeError::Internal(format!("Failed to read cached file: {}", e)) );
})?; player
player .play_next(audio_data, track_id)
.play_next(audio_data, track_id) .map_err(RuntimeError::Internal)?;
.map_err(RuntimeError::Internal)?; return Ok(true);
return Ok(true); } else {
log::warn!(
"[V2/GAPLESS] Track {} CMAF v2 bundle present but decrypt failed — skipping offline tier",
track_id
);
}
}
_ => {
let path = std::path::Path::new(&row.segments_path);
if !path.exists() {
log::warn!(
"[V2/GAPLESS/CACHE STALE] Track {} DB entry points to missing file {:?}",
track_id,
path
);
} else {
log::info!(
"[V2/GAPLESS] Track {} from OFFLINE cache (legacy)",
track_id
);
let audio_data = std::fs::read(path).map_err(|e| {
RuntimeError::Internal(format!("Failed to read cached file: {}", e))
})?;
player
.play_next(audio_data, track_id)
.map_err(RuntimeError::Internal)?;
return Ok(true);
}
}
} }
} }
} }