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:
6 files changed
+774
-1
No files matched your search
Generated
+5
@@ -5133,14 +5133,19 @@ dependencies = [
|
||||
name = "qbz-offline-cache"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"lofty",
|
||||
"log",
|
||||
"qbz-cmaf",
|
||||
"qbz-library",
|
||||
"qbz-models",
|
||||
"qbz-qobuz",
|
||||
"qbz-secrets",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user