refactor(offline_cache): lift downloader functions out of legacy_compat

Moves spawn_track_cache_download and try_cmaf_offline_download from
commands_v2/legacy_compat.rs to offline_cache/downloader.rs as
pub(crate) functions. No behavior change. Prepares the V2-clean path
for the upcoming offline cache manager commands.

cc
This commit is contained in:
vicrodh committed 2026-05-05 22:33:34 -06:00
1 parent 4fe2390129
commit a714db937a
2 files changed
+485 -484

No files matched your search

+1 -484
View File
@@ -23,6 +23,7 @@ use crate::library::{LibraryState, LocalTrack};
use crate::lyrics::LyricsState;
use crate::musicbrainz::MusicBrainzSharedState;
use crate::offline::OfflineState;
use crate::offline_cache::downloader::spawn_track_cache_download;
use crate::offline_cache::OfflineCacheState;
use crate::runtime::RuntimeError;
use crate::AppState;
@@ -883,490 +884,6 @@ pub async fn v2_check_albums_fully_cached_batch(
Ok(result)
}
/// Shared helper: spawn the download task for a single track.
/// CMAF-first offline download path (v2 format).
///
/// On success: the encrypted CMAF bundle is persisted under
/// `<offline_root>/tracks-cmaf/<track_id>/`, the per-track AES content key
/// + session infos are wrapped via `qbz-secrets` and stored on the DB row,
/// `cache_format` flips to 2, `mark_complete` fires, and the library row
/// is populated with the same metadata the legacy path would populate
/// (title/artist/album, etc.).
///
/// Returns `Err` for any failure that makes CMAF unusable — the caller
/// falls back to the legacy plain-FLAC path.
async fn try_cmaf_offline_download(
track_id: u64,
bridge: &std::sync::Arc<tokio::sync::RwLock<Option<crate::core_bridge::CoreBridge>>>,
db: &std::sync::Arc<tokio::sync::Mutex<Option<crate::offline_cache::OfflineCacheDb>>>,
offline_root: &str,
library_db: &std::sync::Arc<tokio::sync::Mutex<Option<qbz_library::LibraryDatabase>>>,
client: &std::sync::Arc<tokio::sync::RwLock<crate::api::QobuzClient>>,
app: &tauri::AppHandle,
) -> Result<(), String> {
use qbz_models::Quality;
let offline_root_path = std::path::PathBuf::from(offline_root);
// Progress callback: emit the same `offline:caching_progress` event
// shape the legacy StreamFetcher fires, so the UI's progress ring
// doesn't care whether the bytes came from CMAF or legacy.
//
// Note: one 'started' event here up front so the frontend sees
// 'downloading' status immediately (the ring starts empty); actual
// percentage updates arrive per completed segment.
let _ = app.emit(
"offline:caching_progress",
serde_json::json!({
"trackId": track_id,
"progressPercent": 0u32,
"bytesDownloaded": 0u64,
"totalBytes": serde_json::Value::Null,
"status": "downloading",
}),
);
let app_for_cb = app.clone();
let progress_cb: qbz_qobuz::CmafProgressCallback = std::sync::Arc::new(
move |update: qbz_qobuz::CmafProgressUpdate| {
let percent = if update.n_segments > 0 {
(update.segments_completed as f64 / update.n_segments as f64 * 100.0)
.round()
.clamp(0.0, 100.0) as u32
} else {
0u32
};
let _ = app_for_cb.emit(
"offline:caching_progress",
serde_json::json!({
"trackId": track_id,
"progressPercent": percent,
"bytesDownloaded": update.bytes_this_segment,
"totalBytes": serde_json::Value::Null,
"status": "downloading",
}),
);
},
);
// Fetch the raw CMAF bundle. Requires an initialized CoreBridge →
// QobuzClient; if either is missing, bail so the legacy path runs.
let bundle = {
let bridge_guard = bridge.read().await;
let bridge = bridge_guard
.as_ref()
.ok_or_else(|| "CoreBridge not initialized".to_string())?;
let client_arc = bridge.core().client();
let client_guard = client_arc.read().await;
let qobuz_client = client_guard
.as_ref()
.ok_or_else(|| "QobuzClient not initialized".to_string())?;
qbz_qobuz::cmaf::download_raw_with_progress(
qobuz_client,
track_id,
Quality::UltraHiRes,
Some(progress_cb),
)
.await?
};
// Open (or lazily init) the secret vault and wrap the keying material
// before it touches the filesystem.
let vault = crate::offline_cache::secret_vault::get_or_init(&offline_root_path)
.map_err(|e| format!("SecretBox init failed: {}", e))?;
let content_key_wrapped = vault
.wrap(&bundle.content_key)
.map_err(|e| format!("Failed to wrap content_key: {}", e))?;
let infos_wrapped = vault
.wrap(bundle.infos.as_bytes())
.map_err(|e| format!("Failed to wrap infos: {}", e))?;
// Persist the encrypted bundle to disk.
let (layout, total_bytes) =
crate::offline_cache::cmaf_store::persist_bundle(&offline_root_path, track_id, &bundle)?;
// Flip the DB row to v2 and store the wrapped keying material.
{
let db_guard = db.lock().await;
let db_ref = db_guard
.as_ref()
.ok_or_else(|| "Offline cache DB not open".to_string())?;
db_ref.set_cmaf_bundle(
track_id,
layout.segments_path.to_string_lossy().as_ref(),
layout.init_path.to_string_lossy().as_ref(),
&content_key_wrapped,
&infos_wrapped,
bundle.format_id,
bundle.n_segments as u32,
total_bytes,
)?;
db_ref
.mark_complete(track_id, total_bytes)
.map_err(|e| format!("Failed to mark_complete: {}", e))?;
}
// Fetch metadata for the library row (same source the legacy path uses).
// We don't write FLAC tags or embed artwork INSIDE the encrypted blob
// — that would corrupt it. But we DO save a cover.jpg next to the
// bundle directory so the library UI has artwork to display.
let metadata = {
let qobuz_client = client.read().await;
crate::offline_cache::metadata::fetch_complete_metadata(track_id, &*qobuz_client).await
};
if let Ok(metadata) = metadata {
// Download and save album artwork alongside the bundle, same as
// the legacy path does next to the FLAC file. cover.jpg lives at
// <offline_root>/tracks-cmaf/<track_id>/cover.jpg — set as the
// library row's artwork_path so the UI picks it up.
let artwork_path: Option<String> = if let Some(artwork_url) = metadata.artwork_url.as_deref() {
match crate::offline_cache::metadata::save_album_artwork(&layout.track_dir, artwork_url).await {
Ok(()) => {
let cover = layout.track_dir.join("cover.jpg");
if cover.exists() {
Some(cover.to_string_lossy().to_string())
} else {
None
}
}
Err(e) => {
log::warn!("[Offline/CMAF] Track {} artwork save failed: {}", track_id, e);
None
}
}
} else {
None
};
let album_artist = metadata.album_artist.as_ref().unwrap_or(&metadata.artist);
let album_group_key = format!("{}|{}", metadata.album, album_artist);
let lib_opt = library_db.lock().await;
if let Some(lib_guard) = lib_opt.as_ref() {
let _ = lib_guard.insert_qobuz_cached_track_with_grouping(
track_id,
&metadata.title,
&metadata.artist,
Some(&metadata.album),
metadata.album_artist.as_deref(),
metadata.track_number,
metadata.disc_number,
metadata.year,
metadata.duration_secs,
// For v2 bundles the "playable path" in the library index
// is the track directory; the player resolves it through
// the DB's cache_format=2 branch anyway.
layout.track_dir.to_string_lossy().as_ref(),
&album_group_key,
&metadata.album,
bundle.bit_depth,
bundle.sampling_rate.map(|r| r as f64),
artwork_path.as_deref(),
);
}
} else if let Err(e) = metadata {
log::warn!(
"[Offline/CMAF] Track {} post-metadata fetch failed: {} (bundle already persisted)",
track_id,
e
);
}
log::info!(
"[Offline/CMAF] Track {} cached as v2 bundle: {:.2} MB under {:?}",
track_id,
total_bytes as f64 / (1024.0 * 1024.0),
layout.track_dir
);
let _ = app.emit(
"offline:caching_completed",
serde_json::json!({
"trackId": track_id,
"size": total_bytes,
"format": "cmaf",
}),
);
let _ = app.emit(
"offline:caching_processed",
serde_json::json!({
"trackId": track_id,
"path": layout.track_dir.to_string_lossy(),
"format": "cmaf",
}),
);
Ok(())
}
/// Used by both v2_cache_track_for_offline (single) and v2_cache_tracks_batch_for_offline (batch).
#[allow(clippy::too_many_arguments)]
fn spawn_track_cache_download(
track_id: u64,
file_path: std::path::PathBuf,
client: std::sync::Arc<tokio::sync::RwLock<crate::api::QobuzClient>>,
bridge: std::sync::Arc<tokio::sync::RwLock<Option<crate::core_bridge::CoreBridge>>>,
fetcher: std::sync::Arc<crate::offline_cache::StreamFetcher>,
db: std::sync::Arc<tokio::sync::Mutex<Option<crate::offline_cache::OfflineCacheDb>>>,
offline_root: String,
library_db: std::sync::Arc<tokio::sync::Mutex<Option<qbz_library::LibraryDatabase>>>,
app: tauri::AppHandle,
semaphore: std::sync::Arc<tokio::sync::Semaphore>,
) {
tokio::spawn(async move {
let _permit = match semaphore.acquire_owned().await {
Ok(permit) => permit,
Err(err) => {
log::error!(
"Failed to acquire cache slot for track {}: {}",
track_id,
err
);
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_status(
track_id,
crate::offline_cache::OfflineCacheStatus::Failed,
Some("Failed to start caching"),
);
}
let _ = app.emit(
"offline:caching_failed",
serde_json::json!({
"trackId": track_id,
"error": "Failed to acquire cache slot"
}),
);
return;
}
};
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_status(
track_id,
crate::offline_cache::OfflineCacheStatus::Downloading,
None,
);
}
let _ = app.emit(
"offline:caching_started",
serde_json::json!({ "trackId": track_id }),
);
// === CMAF-first offline download (v2 format) ===
//
// Stores bit-identical encrypted segments + wrapped content key.
// Falls through to the legacy path below if any step fails (no
// CoreBridge yet, /file/url returns a non-CMAF response, network
// flake, vault init failure, etc.). The legacy fallback keeps
// existing users unblocked while we validate the new path.
match try_cmaf_offline_download(
track_id,
&bridge,
&db,
&offline_root,
&library_db,
&client,
&app,
)
.await
{
Ok(()) => return,
Err(e) => {
log::warn!(
"[Offline/CMAF] Track {} — CMAF path failed ({}), falling back to legacy /track/getFileUrl",
track_id,
e
);
}
}
let stream_url = {
let client_guard = client.read().await;
client_guard
.get_stream_url_with_fallback(track_id, crate::api::models::Quality::UltraHiRes)
.await
};
let url = match stream_url {
Ok(s) => s.url,
Err(e) => {
log::error!("Failed to get stream URL for track {}: {}", track_id, e);
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_status(
track_id,
crate::offline_cache::OfflineCacheStatus::Failed,
Some(&format!("Failed to get stream URL: {}", e)),
);
}
let _ = app.emit(
"offline:caching_failed",
serde_json::json!({
"trackId": track_id,
"error": e.to_string()
}),
);
return;
}
};
match fetcher
.fetch_to_file(&url, &file_path, track_id, Some(&app))
.await
{
Ok(size) => {
log::info!("Caching complete for track {}: {} bytes", track_id, size);
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.mark_complete(track_id, size);
}
let _ = app.emit(
"offline:caching_completed",
serde_json::json!({
"trackId": track_id,
"size": size
}),
);
// Post-processing kept in V2 to avoid command->command delegation.
let file_path_str = file_path.to_string_lossy().to_string();
let qobuz_client = client.read().await;
let metadata = match crate::offline_cache::metadata::fetch_complete_metadata(
track_id,
&*qobuz_client,
)
.await
{
Ok(m) => m,
Err(e) => {
log::warn!(
"Post-processing metadata fetch failed for {}: {}",
track_id,
e
);
return;
}
};
if let Err(e) =
crate::offline_cache::metadata::write_flac_tags(&file_path_str, &metadata)
{
log::warn!("Failed to write tags for {}: {}", track_id, e);
}
if let Some(artwork_url) = &metadata.artwork_url {
if let Err(e) =
crate::offline_cache::metadata::embed_artwork(&file_path_str, artwork_url)
.await
{
log::warn!("Failed to embed artwork for {}: {}", track_id, e);
}
}
let new_path = match crate::offline_cache::metadata::organize_cached_file(
track_id,
&file_path_str,
&offline_root,
&metadata,
) {
Ok(p) => p,
Err(e) => {
log::warn!("Failed to organize cached file {}: {}", track_id, e);
return;
}
};
// Save cover.jpg next to the organized FLAC so the library
// UI has artwork to display.
let artwork_path_v1: Option<String> =
if let Some(artwork_url) = metadata.artwork_url.as_deref() {
if let Some(parent_dir) = std::path::Path::new(&new_path).parent() {
match crate::offline_cache::metadata::save_album_artwork(
parent_dir,
artwork_url,
)
.await
{
Ok(()) => {
let cover = parent_dir.join("cover.jpg");
if cover.exists() {
Some(cover.to_string_lossy().to_string())
} else {
None
}
}
Err(_) => None,
}
} else {
None
}
} else {
None
};
let (bit_depth_detected, sample_rate_detected) =
match lofty::read_from_path(&new_path) {
Ok(tagged_file) => {
use lofty::prelude::*;
let properties = tagged_file.properties();
(
properties.bit_depth().map(|bd| bd as u32),
properties.sample_rate().map(|sr| sr as f64),
)
}
Err(_) => (None, None),
};
let album_artist = metadata.album_artist.as_ref().unwrap_or(&metadata.artist);
let album_group_key = format!("{}|{}", metadata.album, album_artist);
let lib_opt = library_db.lock().await;
if let Some(lib_guard) = lib_opt.as_ref() {
let _ = lib_guard.insert_qobuz_cached_track_with_grouping(
track_id,
&metadata.title,
&metadata.artist,
Some(&metadata.album),
metadata.album_artist.as_deref(),
metadata.track_number,
metadata.disc_number,
metadata.year,
metadata.duration_secs,
&new_path,
&album_group_key,
&metadata.album,
bit_depth_detected,
sample_rate_detected,
artwork_path_v1.as_deref(),
);
}
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_file_path(track_id, &new_path);
}
let _ = app.emit(
"offline:caching_processed",
serde_json::json!({
"trackId": track_id,
"path": new_path
}),
);
}
Err(e) => {
log::error!("Caching failed for track {}: {}", track_id, e);
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_status(
track_id,
crate::offline_cache::OfflineCacheStatus::Failed,
Some(&e),
);
}
let _ = app.emit(
"offline:caching_failed",
serde_json::json!({
"trackId": track_id,
"error": e
}),
);
}
}
});
}
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn v2_cache_track_for_offline(
+484
View File
@@ -249,3 +249,487 @@ impl Default for StreamFetcher {
Self::new()
}
}
/// Shared helper: spawn the download task for a single track.
/// CMAF-first offline download path (v2 format).
///
/// On success: the encrypted CMAF bundle is persisted under
/// `<offline_root>/tracks-cmaf/<track_id>/`, the per-track AES content key
/// + session infos are wrapped via `qbz-secrets` and stored on the DB row,
/// `cache_format` flips to 2, `mark_complete` fires, and the library row
/// is populated with the same metadata the legacy path would populate
/// (title/artist/album, etc.).
///
/// Returns `Err` for any failure that makes CMAF unusable — the caller
/// falls back to the legacy plain-FLAC path.
pub(crate) async fn try_cmaf_offline_download(
track_id: u64,
bridge: &std::sync::Arc<tokio::sync::RwLock<Option<crate::core_bridge::CoreBridge>>>,
db: &std::sync::Arc<tokio::sync::Mutex<Option<crate::offline_cache::OfflineCacheDb>>>,
offline_root: &str,
library_db: &std::sync::Arc<tokio::sync::Mutex<Option<qbz_library::LibraryDatabase>>>,
client: &std::sync::Arc<tokio::sync::RwLock<crate::api::QobuzClient>>,
app: &tauri::AppHandle,
) -> Result<(), String> {
use qbz_models::Quality;
let offline_root_path = std::path::PathBuf::from(offline_root);
// Progress callback: emit the same `offline:caching_progress` event
// shape the legacy StreamFetcher fires, so the UI's progress ring
// doesn't care whether the bytes came from CMAF or legacy.
//
// Note: one 'started' event here up front so the frontend sees
// 'downloading' status immediately (the ring starts empty); actual
// percentage updates arrive per completed segment.
let _ = app.emit(
"offline:caching_progress",
serde_json::json!({
"trackId": track_id,
"progressPercent": 0u32,
"bytesDownloaded": 0u64,
"totalBytes": serde_json::Value::Null,
"status": "downloading",
}),
);
let app_for_cb = app.clone();
let progress_cb: qbz_qobuz::CmafProgressCallback = std::sync::Arc::new(
move |update: qbz_qobuz::CmafProgressUpdate| {
let percent = if update.n_segments > 0 {
(update.segments_completed as f64 / update.n_segments as f64 * 100.0)
.round()
.clamp(0.0, 100.0) as u32
} else {
0u32
};
let _ = app_for_cb.emit(
"offline:caching_progress",
serde_json::json!({
"trackId": track_id,
"progressPercent": percent,
"bytesDownloaded": update.bytes_this_segment,
"totalBytes": serde_json::Value::Null,
"status": "downloading",
}),
);
},
);
// Fetch the raw CMAF bundle. Requires an initialized CoreBridge →
// QobuzClient; if either is missing, bail so the legacy path runs.
let bundle = {
let bridge_guard = bridge.read().await;
let bridge = bridge_guard
.as_ref()
.ok_or_else(|| "CoreBridge not initialized".to_string())?;
let client_arc = bridge.core().client();
let client_guard = client_arc.read().await;
let qobuz_client = client_guard
.as_ref()
.ok_or_else(|| "QobuzClient not initialized".to_string())?;
qbz_qobuz::cmaf::download_raw_with_progress(
qobuz_client,
track_id,
Quality::UltraHiRes,
Some(progress_cb),
)
.await?
};
// Open (or lazily init) the secret vault and wrap the keying material
// before it touches the filesystem.
let vault = crate::offline_cache::secret_vault::get_or_init(&offline_root_path)
.map_err(|e| format!("SecretBox init failed: {}", e))?;
let content_key_wrapped = vault
.wrap(&bundle.content_key)
.map_err(|e| format!("Failed to wrap content_key: {}", e))?;
let infos_wrapped = vault
.wrap(bundle.infos.as_bytes())
.map_err(|e| format!("Failed to wrap infos: {}", e))?;
// Persist the encrypted bundle to disk.
let (layout, total_bytes) =
crate::offline_cache::cmaf_store::persist_bundle(&offline_root_path, track_id, &bundle)?;
// Flip the DB row to v2 and store the wrapped keying material.
{
let db_guard = db.lock().await;
let db_ref = db_guard
.as_ref()
.ok_or_else(|| "Offline cache DB not open".to_string())?;
db_ref.set_cmaf_bundle(
track_id,
layout.segments_path.to_string_lossy().as_ref(),
layout.init_path.to_string_lossy().as_ref(),
&content_key_wrapped,
&infos_wrapped,
bundle.format_id,
bundle.n_segments as u32,
total_bytes,
)?;
db_ref
.mark_complete(track_id, total_bytes)
.map_err(|e| format!("Failed to mark_complete: {}", e))?;
}
// Fetch metadata for the library row (same source the legacy path uses).
// We don't write FLAC tags or embed artwork INSIDE the encrypted blob
// — that would corrupt it. But we DO save a cover.jpg next to the
// bundle directory so the library UI has artwork to display.
let metadata = {
let qobuz_client = client.read().await;
crate::offline_cache::metadata::fetch_complete_metadata(track_id, &*qobuz_client).await
};
if let Ok(metadata) = metadata {
// Download and save album artwork alongside the bundle, same as
// the legacy path does next to the FLAC file. cover.jpg lives at
// <offline_root>/tracks-cmaf/<track_id>/cover.jpg — set as the
// library row's artwork_path so the UI picks it up.
let artwork_path: Option<String> = if let Some(artwork_url) = metadata.artwork_url.as_deref() {
match crate::offline_cache::metadata::save_album_artwork(&layout.track_dir, artwork_url).await {
Ok(()) => {
let cover = layout.track_dir.join("cover.jpg");
if cover.exists() {
Some(cover.to_string_lossy().to_string())
} else {
None
}
}
Err(e) => {
log::warn!("[Offline/CMAF] Track {} artwork save failed: {}", track_id, e);
None
}
}
} else {
None
};
let album_artist = metadata.album_artist.as_ref().unwrap_or(&metadata.artist);
let album_group_key = format!("{}|{}", metadata.album, album_artist);
let lib_opt = library_db.lock().await;
if let Some(lib_guard) = lib_opt.as_ref() {
let _ = lib_guard.insert_qobuz_cached_track_with_grouping(
track_id,
&metadata.title,
&metadata.artist,
Some(&metadata.album),
metadata.album_artist.as_deref(),
metadata.track_number,
metadata.disc_number,
metadata.year,
metadata.duration_secs,
// For v2 bundles the "playable path" in the library index
// is the track directory; the player resolves it through
// the DB's cache_format=2 branch anyway.
layout.track_dir.to_string_lossy().as_ref(),
&album_group_key,
&metadata.album,
bundle.bit_depth,
bundle.sampling_rate.map(|r| r as f64),
artwork_path.as_deref(),
);
}
} else if let Err(e) = metadata {
log::warn!(
"[Offline/CMAF] Track {} post-metadata fetch failed: {} (bundle already persisted)",
track_id,
e
);
}
log::info!(
"[Offline/CMAF] Track {} cached as v2 bundle: {:.2} MB under {:?}",
track_id,
total_bytes as f64 / (1024.0 * 1024.0),
layout.track_dir
);
let _ = app.emit(
"offline:caching_completed",
serde_json::json!({
"trackId": track_id,
"size": total_bytes,
"format": "cmaf",
}),
);
let _ = app.emit(
"offline:caching_processed",
serde_json::json!({
"trackId": track_id,
"path": layout.track_dir.to_string_lossy(),
"format": "cmaf",
}),
);
Ok(())
}
/// Used by both v2_cache_track_for_offline (single) and v2_cache_tracks_batch_for_offline (batch).
#[allow(clippy::too_many_arguments)]
pub(crate) fn spawn_track_cache_download(
track_id: u64,
file_path: std::path::PathBuf,
client: std::sync::Arc<tokio::sync::RwLock<crate::api::QobuzClient>>,
bridge: std::sync::Arc<tokio::sync::RwLock<Option<crate::core_bridge::CoreBridge>>>,
fetcher: std::sync::Arc<crate::offline_cache::StreamFetcher>,
db: std::sync::Arc<tokio::sync::Mutex<Option<crate::offline_cache::OfflineCacheDb>>>,
offline_root: String,
library_db: std::sync::Arc<tokio::sync::Mutex<Option<qbz_library::LibraryDatabase>>>,
app: tauri::AppHandle,
semaphore: std::sync::Arc<tokio::sync::Semaphore>,
) {
tokio::spawn(async move {
let _permit = match semaphore.acquire_owned().await {
Ok(permit) => permit,
Err(err) => {
log::error!(
"Failed to acquire cache slot for track {}: {}",
track_id,
err
);
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_status(
track_id,
crate::offline_cache::OfflineCacheStatus::Failed,
Some("Failed to start caching"),
);
}
let _ = app.emit(
"offline:caching_failed",
serde_json::json!({
"trackId": track_id,
"error": "Failed to acquire cache slot"
}),
);
return;
}
};
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_status(
track_id,
crate::offline_cache::OfflineCacheStatus::Downloading,
None,
);
}
let _ = app.emit(
"offline:caching_started",
serde_json::json!({ "trackId": track_id }),
);
// === CMAF-first offline download (v2 format) ===
//
// Stores bit-identical encrypted segments + wrapped content key.
// Falls through to the legacy path below if any step fails (no
// CoreBridge yet, /file/url returns a non-CMAF response, network
// flake, vault init failure, etc.). The legacy fallback keeps
// existing users unblocked while we validate the new path.
match try_cmaf_offline_download(
track_id,
&bridge,
&db,
&offline_root,
&library_db,
&client,
&app,
)
.await
{
Ok(()) => return,
Err(e) => {
log::warn!(
"[Offline/CMAF] Track {} — CMAF path failed ({}), falling back to legacy /track/getFileUrl",
track_id,
e
);
}
}
let stream_url = {
let client_guard = client.read().await;
client_guard
.get_stream_url_with_fallback(track_id, crate::api::models::Quality::UltraHiRes)
.await
};
let url = match stream_url {
Ok(s) => s.url,
Err(e) => {
log::error!("Failed to get stream URL for track {}: {}", track_id, e);
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_status(
track_id,
crate::offline_cache::OfflineCacheStatus::Failed,
Some(&format!("Failed to get stream URL: {}", e)),
);
}
let _ = app.emit(
"offline:caching_failed",
serde_json::json!({
"trackId": track_id,
"error": e.to_string()
}),
);
return;
}
};
match fetcher
.fetch_to_file(&url, &file_path, track_id, Some(&app))
.await
{
Ok(size) => {
log::info!("Caching complete for track {}: {} bytes", track_id, size);
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.mark_complete(track_id, size);
}
let _ = app.emit(
"offline:caching_completed",
serde_json::json!({
"trackId": track_id,
"size": size
}),
);
// Post-processing kept in V2 to avoid command->command delegation.
let file_path_str = file_path.to_string_lossy().to_string();
let qobuz_client = client.read().await;
let metadata = match crate::offline_cache::metadata::fetch_complete_metadata(
track_id,
&*qobuz_client,
)
.await
{
Ok(m) => m,
Err(e) => {
log::warn!(
"Post-processing metadata fetch failed for {}: {}",
track_id,
e
);
return;
}
};
if let Err(e) =
crate::offline_cache::metadata::write_flac_tags(&file_path_str, &metadata)
{
log::warn!("Failed to write tags for {}: {}", track_id, e);
}
if let Some(artwork_url) = &metadata.artwork_url {
if let Err(e) =
crate::offline_cache::metadata::embed_artwork(&file_path_str, artwork_url)
.await
{
log::warn!("Failed to embed artwork for {}: {}", track_id, e);
}
}
let new_path = match crate::offline_cache::metadata::organize_cached_file(
track_id,
&file_path_str,
&offline_root,
&metadata,
) {
Ok(p) => p,
Err(e) => {
log::warn!("Failed to organize cached file {}: {}", track_id, e);
return;
}
};
// Save cover.jpg next to the organized FLAC so the library
// UI has artwork to display.
let artwork_path_v1: Option<String> =
if let Some(artwork_url) = metadata.artwork_url.as_deref() {
if let Some(parent_dir) = std::path::Path::new(&new_path).parent() {
match crate::offline_cache::metadata::save_album_artwork(
parent_dir,
artwork_url,
)
.await
{
Ok(()) => {
let cover = parent_dir.join("cover.jpg");
if cover.exists() {
Some(cover.to_string_lossy().to_string())
} else {
None
}
}
Err(_) => None,
}
} else {
None
}
} else {
None
};
let (bit_depth_detected, sample_rate_detected) =
match lofty::read_from_path(&new_path) {
Ok(tagged_file) => {
use lofty::prelude::*;
let properties = tagged_file.properties();
(
properties.bit_depth().map(|bd| bd as u32),
properties.sample_rate().map(|sr| sr as f64),
)
}
Err(_) => (None, None),
};
let album_artist = metadata.album_artist.as_ref().unwrap_or(&metadata.artist);
let album_group_key = format!("{}|{}", metadata.album, album_artist);
let lib_opt = library_db.lock().await;
if let Some(lib_guard) = lib_opt.as_ref() {
let _ = lib_guard.insert_qobuz_cached_track_with_grouping(
track_id,
&metadata.title,
&metadata.artist,
Some(&metadata.album),
metadata.album_artist.as_deref(),
metadata.track_number,
metadata.disc_number,
metadata.year,
metadata.duration_secs,
&new_path,
&album_group_key,
&metadata.album,
bit_depth_detected,
sample_rate_detected,
artwork_path_v1.as_deref(),
);
}
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_file_path(track_id, &new_path);
}
let _ = app.emit(
"offline:caching_processed",
serde_json::json!({
"trackId": track_id,
"path": new_path
}),
);
}
Err(e) => {
log::error!("Caching failed for track {}: {}", track_id, e);
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_status(
track_id,
crate::offline_cache::OfflineCacheStatus::Failed,
Some(&e),
);
}
let _ = app.emit(
"offline:caching_failed",
serde_json::json!({
"trackId": track_id,
"error": e
}),
);
}
}
});
}