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:
2 files changed
+123
-57
No files matched your search
@@ -496,11 +496,13 @@ pub async fn v2_library_play_track(
|
||||
.ok_or_else(|| "Track not found".to_string())?
|
||||
};
|
||||
|
||||
// Qobuz-cached offline tracks may be stored in the new v2 CMAF format
|
||||
// (cache_format=2), in which case `track.file_path` in the library
|
||||
// index points at a directory, not a playable FLAC. Route those
|
||||
// through the offline-cache playback helper; it decrypts and hands us
|
||||
// plain FLAC bytes identical to what a v1 cached file would yield.
|
||||
// Qobuz-cached offline tracks are authoritative in the offline-cache
|
||||
// DB, not the library index. The library row for these tracks carries
|
||||
// metadata only (title/artist/album) plus a display path; the library's
|
||||
// `file_path` for v2 entries is a track directory that is not playable
|
||||
// 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");
|
||||
if is_qobuz_cached {
|
||||
let bundle_row = {
|
||||
@@ -509,42 +511,75 @@ pub async fn v2_library_play_track(
|
||||
.as_ref()
|
||||
.and_then(|db| db.get_cmaf_bundle(track_id as u64).ok().flatten())
|
||||
};
|
||||
if let Some(row) = bundle_row {
|
||||
if row.cache_format == 2 {
|
||||
match bundle_row {
|
||||
Some(row) if row.cache_format == 2 => {
|
||||
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,
|
||||
&row,
|
||||
std::path::Path::new(&cache_path),
|
||||
) {
|
||||
let bridge = bridge.get().await;
|
||||
bridge
|
||||
.player()
|
||||
.play_data(audio_data, track_id as u64)
|
||||
.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;
|
||||
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))?;
|
||||
}
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Offline CMAF bundle for track {} is present but failed to decrypt",
|
||||
track_id
|
||||
)
|
||||
})?;
|
||||
let bridge = bridge.get().await;
|
||||
bridge
|
||||
.player()
|
||||
.play_data(audio_data, track_id as u64)
|
||||
.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;
|
||||
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!(
|
||||
"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
|
||||
));
|
||||
}
|
||||
// cache_format=1 — fall through to the legacy plain-FLAC branch
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy plain-FLAC path: v1 Qobuz-cached entries OR regular user
|
||||
// local library files. Read the file directly and play.
|
||||
// Regular user local library files (FLAC/MP3 owned by the user,
|
||||
// not Qobuz-cached). Read the file path from the library row.
|
||||
let file_path = std::path::Path::new(&track.file_path);
|
||||
if !file_path.exists() {
|
||||
return Err(format!("File not found: {}", track.file_path));
|
||||
|
||||
@@ -436,38 +436,69 @@ pub async fn v2_play_next_gapless(
|
||||
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;
|
||||
if let Some(db) = db_opt.as_ref() {
|
||||
if let Ok(Some(file_path)) = db.get_file_path(track_id) {
|
||||
Some(file_path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
db_opt
|
||||
.as_ref()
|
||||
.and_then(|db| db.get_cmaf_bundle(track_id).ok().flatten())
|
||||
};
|
||||
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| {
|
||||
RuntimeError::Internal(format!("Failed to read cached file: {}", e))
|
||||
})?;
|
||||
player
|
||||
.play_next(audio_data, track_id)
|
||||
.map_err(RuntimeError::Internal)?;
|
||||
return Ok(true);
|
||||
if let Some(row) = bundle_row {
|
||||
match row.cache_format {
|
||||
2 => {
|
||||
let cache_path = offline_cache.get_cache_path();
|
||||
if let Some(audio_data) = crate::offline_cache::playback::load_cmaf_bundle(
|
||||
track_id,
|
||||
&row,
|
||||
std::path::Path::new(&cache_path),
|
||||
) {
|
||||
log::info!(
|
||||
"[V2/GAPLESS] Track {} from OFFLINE cache (CMAF v2)",
|
||||
track_id
|
||||
);
|
||||
player
|
||||
.play_next(audio_data, track_id)
|
||||
.map_err(RuntimeError::Internal)?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user