feat(offline-cache): move metadata/migration + playback pure-fn into the crate

Continues Slice 0. Moves the near-pure modules, re-pointing the src-tauri
client/library types to the core crates (the make-or-break for a mechanical
extraction — verified the field shapes match):

- metadata: fetch_complete_metadata now takes &qbz_qobuz::QobuzClient (was
  crate::api::QobuzClient); body compiles unchanged against qbz_models.
- migration: crate::api::QobuzClient -> qbz_qobuz::QobuzClient,
  crate::library::database::LibraryDatabase -> qbz_library::LibraryDatabase,
  super::metadata -> crate::metadata.
- playback: the PURE load_cmaf_bundle fn (the UI-events wrapper stays out —
  it becomes a CacheEventSink call in the next commit).

Deps added: qbz-models, qbz-library, tokio, reqwest, lofty. Crate compiles.
purge + OfflineCacheState + downloader (CacheEvent) remain.

cc
This commit is contained in:
vicrodh committed 2026-05-27 19:27:39 -06:00
1 parent d6e9345f5a
commit 5f88201736
6 files changed
+774 -1

No files matched your search

+10 -1
View File
@@ -6,14 +6,23 @@ license.workspace = true
description = "Offline cache for QBZ: download-for-offline store, CMAF bundle vault, SQLite index, cached playback resolution (frontend-agnostic)"
[dependencies]
# Sibling crates: the network + crypto + init-parse already live here.
# Sibling crates: the network + crypto + init-parse + library index already live here.
qbz-models = { path = "../qbz-models" }
qbz-qobuz = { path = "../qbz-qobuz" }
qbz-cmaf = { path = "../qbz-cmaf" }
qbz-secrets = { path = "../qbz-secrets" }
qbz-library = { path = "../qbz-library" }
# SQLite index (match qbz-library's version/features)
rusqlite = { version = "0.31", features = ["bundled"] }
# Async runtime + HTTP (download pipeline + artwork)
tokio = { workspace = true }
reqwest = { workspace = true }
# FLAC tag + artwork embedding (legacy-format post-processing)
lofty = "0.23"
# DTO serialization (camelCase envelopes shared with the frontend)
serde = { workspace = true }
serde_json = { workspace = true }
+8
View File
@@ -17,12 +17,20 @@
pub mod cmaf_store;
pub mod db;
pub mod maintenance;
pub mod metadata;
pub mod migration;
pub mod path_validator;
pub mod playback;
pub mod secret_vault;
pub mod types;
pub use db::{CmafBundleRow, OfflineCacheDb};
pub use metadata::{sanitize_filename, CompleteTrackMetadata};
pub use migration::{
detect_legacy_cached_files, migrate_legacy_cached_files, MigrationError, MigrationStatus,
};
pub use path_validator::{is_offline_root_available, validate_path, PathStatus};
pub use playback::load_cmaf_bundle;
pub use types::{
CacheProgress, CachedTrackInfo, OfflineCacheStats, OfflineCacheStatus, ReadyTrackForSync,
TrackCacheInfo,
+367
View File
@@ -0,0 +1,367 @@
//! Metadata fetching and FLAC tagging for cached tracks
//!
//! Handles complete metadata retrieval from Qobuz API and writing to FLAC tags.
use lofty::config::WriteOptions;
use lofty::picture::{MimeType, Picture, PictureType};
use lofty::prelude::*;
use lofty::tag::{ItemKey, Tag};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompleteTrackMetadata {
pub track_id: u64,
pub title: String,
pub artist: String,
pub album: String,
pub album_artist: Option<String>,
pub track_number: Option<u32>,
pub disc_number: Option<u32>,
pub year: Option<u32>,
pub genre: Option<String>,
pub isrc: Option<String>,
pub label: Option<String>,
pub copyright: Option<String>,
pub composer: Option<String>,
pub duration_secs: u64,
pub artwork_url: Option<String>,
}
/// Fetch complete metadata for a track from Qobuz API
pub async fn fetch_complete_metadata(
track_id: u64,
qobuz_client: &qbz_qobuz::QobuzClient,
) -> Result<CompleteTrackMetadata, String> {
log::info!("Fetching complete metadata for track {}", track_id);
let track = qobuz_client
.get_track(track_id)
.await
.map_err(|e| format!("Failed to fetch track: {}", e))?;
let album = if let Some(album_obj) = &track.album {
qobuz_client.get_album(&album_obj.id).await.ok()
} else {
None
};
let album_artist = album
.as_ref()
.map(|a| a.artist.name.clone())
.or_else(|| track.performer.as_ref().map(|p| p.name.clone()));
let genre = album
.as_ref()
.and_then(|a| a.genre.as_ref())
.map(|g| g.name.clone());
let label = album
.as_ref()
.and_then(|a| a.label.as_ref())
.map(|l| l.name.clone());
let year = album
.as_ref()
.and_then(|a| a.release_date_original.as_ref())
.and_then(|date_str| {
// Parse YYYY-MM-DD or YYYY format
date_str
.split('-')
.next()
.and_then(|year_str| year_str.parse::<u32>().ok())
});
let artwork_url = album
.as_ref()
.and_then(|a| a.image.large.clone())
.or_else(|| track.album.as_ref().and_then(|a| a.image.large.clone()));
Ok(CompleteTrackMetadata {
track_id,
title: track.title,
artist: track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default(),
album: track
.album
.as_ref()
.map(|a| a.title.clone())
.unwrap_or_default(),
album_artist,
track_number: Some(track.track_number),
disc_number: track.media_number,
year,
genre,
isrc: track.isrc.clone(),
label,
copyright: None, // Qobuz API doesn't provide copyright in album model
composer: None, // Qobuz API doesn't provide composer in track model
duration_secs: track.duration as u64,
artwork_url,
})
}
/// Write metadata tags to a FLAC file
pub fn write_flac_tags(file_path: &str, metadata: &CompleteTrackMetadata) -> Result<(), String> {
log::info!("Writing FLAC tags to: {}", file_path);
let path = Path::new(file_path);
let mut tagged_file =
lofty::read_from_path(path).map_err(|e| format!("Failed to read FLAC file: {}", e))?;
let tag = match tagged_file.primary_tag_mut() {
Some(primary_tag) => primary_tag,
None => {
let tag_type = tagged_file.primary_tag_type();
tagged_file.insert_tag(Tag::new(tag_type));
tagged_file.primary_tag_mut().unwrap()
}
};
// Clear existing tags
tag.clear();
// Write standard Vorbis comments
tag.set_title(metadata.title.clone());
tag.set_artist(metadata.artist.clone());
tag.set_album(metadata.album.clone());
if let Some(album_artist) = &metadata.album_artist {
tag.insert_text(ItemKey::AlbumArtist, album_artist.clone());
}
if let Some(track_number) = metadata.track_number {
tag.set_track(track_number);
}
if let Some(disc_number) = metadata.disc_number {
tag.set_disk(disc_number);
}
if let Some(year) = metadata.year {
tag.set_date(lofty::tag::items::Timestamp {
year: year as u16,
..Default::default()
});
}
if let Some(genre) = &metadata.genre {
tag.set_genre(genre.clone());
}
if let Some(isrc) = &metadata.isrc {
tag.insert_text(ItemKey::Isrc, isrc.clone());
}
if let Some(label) = &metadata.label {
tag.insert_text(ItemKey::Label, label.clone());
}
if let Some(copyright) = &metadata.copyright {
tag.insert_text(ItemKey::CopyrightMessage, copyright.clone());
}
if let Some(composer) = &metadata.composer {
tag.insert_text(ItemKey::Composer, composer.clone());
}
// Save tags
tagged_file
.save_to_path(path, WriteOptions::default())
.map_err(|e| format!("Failed to save tags: {}", e))?;
Ok(())
}
/// Download and embed artwork in FLAC file
pub async fn embed_artwork(file_path: &str, artwork_url: &str) -> Result<(), String> {
log::info!("Embedding artwork from: {}", artwork_url);
// Download artwork
let response = reqwest::get(artwork_url)
.await
.map_err(|e| format!("Failed to download artwork: {}", e))?;
let artwork_bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read artwork bytes: {}", e))?;
// Determine MIME type from URL
let mime_type = if artwork_url.contains(".jpg") || artwork_url.contains(".jpeg") {
MimeType::Jpeg
} else if artwork_url.contains(".png") {
MimeType::Png
} else {
MimeType::Jpeg // Default to JPEG
};
// Create picture
let picture = Picture::unchecked(artwork_bytes.to_vec())
.pic_type(PictureType::CoverFront)
.mime_type(mime_type)
.build();
// Read file
let path = Path::new(file_path);
let mut tagged_file =
lofty::read_from_path(path).map_err(|e| format!("Failed to read FLAC file: {}", e))?;
// Add picture to primary tag
if let Some(tag) = tagged_file.primary_tag_mut() {
tag.push_picture(picture);
} else {
let tag_type = tagged_file.primary_tag_type();
let mut tag = Tag::new(tag_type);
tag.push_picture(picture);
tagged_file.insert_tag(tag);
}
// Save
tagged_file
.save_to_path(path, WriteOptions::default())
.map_err(|e| format!("Failed to save artwork: {}", e))?;
Ok(())
}
/// Sanitize filename to be ASCII-safe and filesystem-compatible
pub fn sanitize_filename(name: &str) -> String {
// Remove or replace invalid characters
let invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
let mut sanitized = name
.chars()
.map(|c| {
if invalid_chars.contains(&c) {
'-'
} else if c.is_ascii() || c.is_alphanumeric() {
c
} else {
'-'
}
})
.collect::<String>();
// Replace multiple consecutive dashes with single dash
while sanitized.contains("--") {
sanitized = sanitized.replace("--", "-");
}
// Trim dashes and whitespace from ends
sanitized = sanitized.trim_matches('-').trim().to_string();
// Limit length to 200 chars (leaving room for extension and path)
if sanitized.len() > 200 {
sanitized.truncate(200);
sanitized = sanitized.trim_matches('-').trim().to_string();
}
// If empty after sanitization, use fallback
if sanitized.is_empty() {
sanitized = "track".to_string();
}
sanitized
}
/// Download and save album cover art as a file
pub async fn save_album_artwork(album_dir: &Path, artwork_url: &str) -> Result<(), String> {
log::info!("Downloading album artwork to: {:?}", album_dir);
let cover_path = album_dir.join("cover.jpg");
// Skip if cover already exists
if cover_path.exists() {
log::debug!("Cover art already exists at {:?}", cover_path);
return Ok(());
}
// Download artwork
let response = reqwest::get(artwork_url)
.await
.map_err(|e| format!("Failed to download album artwork: {}", e))?;
let artwork_bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read album artwork bytes: {}", e))?;
// Write to file
std::fs::write(&cover_path, artwork_bytes)
.map_err(|e| format!("Failed to write cover.jpg: {}", e))?;
log::info!("Album artwork saved to: {:?}", cover_path);
Ok(())
}
/// Organize cached file into proper folder structure
pub fn organize_cached_file(
track_id: u64,
temp_path: &str,
root_dir: &str,
metadata: &CompleteTrackMetadata,
) -> Result<String, String> {
log::info!("Organizing cached file for track {}", track_id);
let temp = Path::new(temp_path);
let root = Path::new(root_dir);
// Build target path: <root>/<artist>/<album>/[Disc N/]NN - Title.flac
let artist_dir = sanitize_filename(&metadata.album_artist.as_ref().unwrap_or(&metadata.artist));
let album_dir = sanitize_filename(&metadata.album);
let mut target_dir = root.join(&artist_dir).join(&album_dir);
// Add disc subfolder if multi-disc
if let Some(disc) = metadata.disc_number {
if disc > 1 {
target_dir = target_dir.join(format!("Disc {}", disc));
}
}
// Create directory structure
std::fs::create_dir_all(&target_dir)
.map_err(|e| format!("Failed to create directories: {}", e))?;
// Build filename: NN - Title.flac
let track_num = metadata.track_number.unwrap_or(0);
let title_clean = sanitize_filename(&metadata.title);
let filename = if track_num > 0 {
format!("{:02} - {}.flac", track_num, title_clean)
} else {
format!("{}.flac", title_clean)
};
let target_path = target_dir.join(&filename);
// Handle filename conflicts
let final_path = if target_path.exists() {
let mut counter = 2;
loop {
let alt_filename = if track_num > 0 {
format!("{:02} - {} ({}).flac", track_num, title_clean, counter)
} else {
format!("{} ({}).flac", title_clean, counter)
};
let alt_path = target_dir.join(&alt_filename);
if !alt_path.exists() {
break alt_path;
}
counter += 1;
if counter > 100 {
return Err("Too many filename conflicts".to_string());
}
}
} else {
target_path
};
// Move file
std::fs::rename(temp, &final_path).map_err(|e| format!("Failed to move file: {}", e))?;
Ok(final_path.to_string_lossy().to_string())
}
+252
View File
@@ -0,0 +1,252 @@
//! Legacy cached file migration service
//!
//! Handles migration of old numeric-named FLAC files to new organized structure
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use crate::metadata::{
embed_artwork, fetch_complete_metadata, organize_cached_file, save_album_artwork,
write_flac_tags,
};
use qbz_library::LibraryDatabase;
use qbz_qobuz::QobuzClient;
#[derive(Default, Serialize, Clone, Debug)]
pub struct MigrationStatus {
pub has_legacy_files: bool,
pub total_tracks: usize,
pub processed: usize,
pub successful: usize,
pub failed: usize,
pub in_progress: bool,
pub completed: bool,
pub errors: Vec<MigrationError>,
}
#[derive(Serialize, Clone, Debug)]
pub struct MigrationError {
pub track_id: u64,
pub error_message: String,
}
/// Detect legacy cached files (numeric FLAC files in tracks/ folder)
pub fn detect_legacy_cached_files(tracks_dir: &Path) -> Result<Vec<u64>, String> {
log::info!(
"Scanning for legacy cached files in: {}",
tracks_dir.display()
);
if !tracks_dir.exists() {
return Ok(Vec::new());
}
let mut track_ids = Vec::new();
match std::fs::read_dir(tracks_dir) {
Ok(entries) => {
for entry in entries.flatten() {
let path = entry.path();
// Only process FLAC files
if path.extension().and_then(|s| s.to_str()) != Some("flac") {
continue;
}
// Check if filename is purely numeric (track_id)
if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
if let Ok(track_id) = filename.parse::<u64>() {
track_ids.push(track_id);
log::debug!("Found legacy track: {}", track_id);
}
}
}
}
Err(e) => {
return Err(format!("Failed to read tracks directory: {}", e));
}
}
log::info!("Found {} legacy cached files", track_ids.len());
Ok(track_ids)
}
/// Migrate a single legacy cached track
async fn migrate_single_track(
track_id: u64,
legacy_path: PathBuf,
offline_root: &str,
qobuz_client: &QobuzClient,
library_db: Arc<Mutex<Option<LibraryDatabase>>>,
) -> Result<String, String> {
log::info!("Migrating track {}", track_id);
// 1. Fetch complete metadata from Qobuz
let metadata = fetch_complete_metadata(track_id, qobuz_client).await?;
// 2. Write FLAC tags
let legacy_path_str = legacy_path.to_string_lossy().to_string();
write_flac_tags(&legacy_path_str, &metadata)
.map_err(|e| format!("Failed to write tags: {}", e))?;
// 3. Embed artwork if available
if let Some(artwork_url) = &metadata.artwork_url {
if let Err(e) = embed_artwork(&legacy_path_str, artwork_url).await {
log::warn!("Failed to embed artwork for track {}: {}", track_id, e);
}
}
// 4. Organize file into artist/album structure
let new_path = organize_cached_file(track_id, &legacy_path_str, offline_root, &metadata)?;
// 5. Save album artwork as cover.jpg
let artwork_path = if let Some(artwork_url) = &metadata.artwork_url {
if let Some(parent_dir) = std::path::Path::new(&new_path).parent() {
match save_album_artwork(parent_dir, artwork_url).await {
Ok(_) => Some(parent_dir.join("cover.jpg").to_string_lossy().to_string()),
Err(e) => {
log::warn!("Failed to save album artwork for track {}: {}", track_id, e);
None
}
}
} else {
None
}
} else {
None
};
// 6. Extract audio properties from FLAC file
use lofty::prelude::*;
let (bit_depth, sample_rate) = match lofty::read_from_path(&new_path) {
Ok(tagged_file) => {
let properties = tagged_file.properties();
let bit_depth = properties.bit_depth().map(|bd| bd as u32);
let sample_rate = properties.sample_rate().map(|sr| sr as f64);
(bit_depth, sample_rate)
}
Err(e) => {
log::warn!(
"Failed to read audio properties for track {}: {}",
track_id,
e
);
(None, None)
}
};
// 7. Insert into local library DB
let lib_opt__ = library_db.lock().await;
let lib_guard = lib_opt__
.as_ref()
.ok_or("No active session - please log in")?;
let album_artist = metadata.album_artist.as_ref().unwrap_or(&metadata.artist);
let album_group_key = format!("{}|{}", metadata.album, album_artist);
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,
sample_rate,
artwork_path.as_deref(),
)
.map_err(|e| format!("Failed to insert to library DB: {}", e))?;
log::info!("Track {} migrated successfully to: {}", track_id, new_path);
Ok(new_path)
}
/// Migrate all legacy cached files
pub async fn migrate_legacy_cached_files(
track_ids: Vec<u64>,
tracks_dir: PathBuf,
offline_root: String,
qobuz_client: Arc<RwLock<QobuzClient>>,
library_db: Arc<Mutex<Option<LibraryDatabase>>>,
) -> MigrationStatus {
let total = track_ids.len();
let mut status = MigrationStatus {
has_legacy_files: true,
total_tracks: total,
in_progress: true,
..Default::default()
};
for track_id in track_ids {
let legacy_path = tracks_dir.join(format!("{}.flac", track_id));
if !legacy_path.exists() {
log::warn!("Legacy file not found: {}", legacy_path.display());
status.processed += 1;
continue;
}
// Lock client for this migration
let client_guard = qobuz_client.read().await;
match migrate_single_track(
track_id,
legacy_path.clone(),
&offline_root,
&*client_guard,
library_db.clone(),
)
.await
{
Ok(_) => {
status.successful += 1;
// Delete legacy file after successful migration
if let Err(e) = std::fs::remove_file(&legacy_path) {
log::warn!(
"Failed to delete legacy file {}: {}",
legacy_path.display(),
e
);
}
}
Err(e) => {
status.failed += 1;
status.errors.push(MigrationError {
track_id,
error_message: e,
});
log::error!(
"Failed to migrate track {}: {}",
track_id,
status.errors.last().unwrap().error_message
);
}
}
drop(client_guard);
status.processed += 1;
}
status.in_progress = false;
status.completed = true;
log::info!(
"Migration complete: {}/{} successful, {} failed",
status.successful,
total,
status.failed
);
status
}
+132
View File
@@ -0,0 +1,132 @@
//! Offline-cache → audio-bytes bridge for playback.
//!
//! When a track is played back from the offline cache (either via the
//! main playback path or via the Local Library path when the track is a
//! Qobuz-cached offline entry), this module converts the stored row
//! into a `Vec<u8>` ready for `player.play_data`.
//!
//! For `cache_format = 2` (v2 CMAF bundle) this means:
//! 1. Read init.mp4 + segments.bin + manifest.json from disk
//! 2. Unwrap the content_key via the secret vault
//! 3. Decrypt the encrypted frames and prepend the FLAC header
//!
//! For `cache_format = 1` (legacy plain FLAC) the caller should just
//! `std::fs::read(file_path)` directly — this module doesn't handle v1
//! since v1 needs no extra work.
//!
//! This is the PURE resolution (no Tauri, no events). The UI-events
//! wrapper that emits unlock-start/end lives in `download` behind the
//! `CacheEventSink`.
use std::path::Path;
use crate::cmaf_store::{self, BundleLayout};
use crate::db::CmafBundleRow;
use crate::secret_vault;
/// Decrypt a v2 CMAF bundle row into plain FLAC bytes ready for
/// `player.play_data`. Returns `None` on any failure (missing init,
/// wrong-size unwrapped key, corrupt manifest, decrypt error). The
/// caller should treat `None` as a cache miss — continue to the next
/// tier or the network.
///
/// `offline_root_path` is only used to locate the secret vault's
/// install UUID file; it must match the path used at download time.
/// Passing `OfflineCacheState::get_cache_path()` is correct.
pub fn load_cmaf_bundle(
track_id: u64,
row: &CmafBundleRow,
offline_root_path: &Path,
) -> Option<Vec<u8>> {
if row.cache_format != 2 {
return None;
}
let init_path = row.init_path.as_ref().or_else(|| {
log::warn!(
"[OfflineCache/Play] Track {} cache_format=2 but init_path is null",
track_id
);
None
})?;
let content_key_wrapped = row.content_key_wrapped.as_ref().or_else(|| {
log::warn!(
"[OfflineCache/Play] Track {} cache_format=2 but content_key_wrapped is null",
track_id
);
None
})?;
let segments_path = std::path::PathBuf::from(&row.segments_path);
let track_dir = segments_path.parent()?.to_path_buf();
let layout = BundleLayout {
track_dir,
init_path: std::path::PathBuf::from(init_path),
segments_path: segments_path.clone(),
manifest_path: segments_path.with_file_name("manifest.json"),
};
let loaded = match cmaf_store::read_bundle(&layout) {
Ok(lb) => lb,
Err(e) => {
log::warn!(
"[OfflineCache/Play] Track {} failed to read CMAF bundle: {}",
track_id,
e
);
return None;
}
};
let vault = match secret_vault::get_or_init(offline_root_path) {
Ok(v) => v,
Err(e) => {
log::warn!(
"[OfflineCache/Play] Track {} SecretBox init failed: {}",
track_id,
e
);
return None;
}
};
let unwrapped = match vault.unwrap(content_key_wrapped) {
Ok(k) => k,
Err(e) => {
log::warn!(
"[OfflineCache/Play] Track {} content_key unwrap failed: {}",
track_id,
e
);
return None;
}
};
if unwrapped.len() != 16 {
log::warn!(
"[OfflineCache/Play] Track {} unwrapped content_key wrong size ({} bytes)",
track_id,
unwrapped.len()
);
return None;
}
let mut content_key = [0u8; 16];
content_key.copy_from_slice(&unwrapped);
match loaded.decrypt_to_flac(&content_key) {
Ok(flac_bytes) => {
log::info!(
"[OfflineCache/Play] Track {} unwrapped + decrypted ({:.2} MB FLAC)",
track_id,
flac_bytes.len() as f64 / (1024.0 * 1024.0)
);
Some(flac_bytes)
}
Err(e) => {
log::warn!(
"[OfflineCache/Play] Track {} CMAF decrypt failed: {}",
track_id,
e
);
None
}
}
}