Files
qbz/crates/qbz-offline-cache/src/metadata.rs
T
vicrodh 475f261dc1 feat(purchases): shared backend for the Qt port, with tags, covers and goodies
The frontend-agnostic half of Purchases. Ported against the Tauri reference,
which is the only implementation ever validated by a real user; the Slint
controller was read for divergences only and never as a source of truth.

Context that sets the bar for all of it: Qobuz does not sell Purchases in this
account's region, so no populated screen and no download can be exercised here.
Anything merely plausible ships unseen. Hence the tests below are the deliverable
as much as the code.

Behaviour ported or corrected:

- Album "downloaded" now comes from the LOCAL REGISTRY. The reference derives it
  from `album.tracks.items`, but `getUserPurchases?type=albums` carries no nested
  tracks page (measured against a live account), so that predicate is
  unsatisfiable: no album card can ever mark as downloaded and "Hide downloaded"
  can never hide anything. `get_downloaded_purchase_album_counts` answers it
  instead, giving a reader at last to the `album_id` column and to the index that
  has always sat on it.
- `album_id` is written by every download path. It used to be set only by the
  album loop, so a release downloaded track by track produced rows the rule could
  never attribute. The `/track/get` payload each download already fetches carries
  the id.
- `remove_downloaded_purchase` keys on `(track_id, format_id)`, the table's actual
  primary key; erasing every format is now a separate, named call.
- `sanitize_filename` no longer panics. The 200 cap is in BYTES and `truncate`
  panics off a char boundary — reachable because the mapping keeps Unicode
  alphanumerics. Measured: the crash belongs to the THREE-byte class (CJK), where
  200 % 3 lands mid-character; two-byte scripts happen to land on a boundary. The
  cap stays in bytes so no already-downloaded path moves.
- The purchase path sends `/album/get` and `/track/get` UNSIGNED, matching both
  the reference and Qobuz's own desktop client, whose request helper takes a sign
  flag and passes it false for these and true only for `getFileUrl`. That
  signature is the entitlement proof and is untouched.
- The purchase-type guard runs before any I/O; a bogus type no longer issues a
  live authenticated GET before failing.
- Track subtitles survive. `PurchaseTrack` gained `version` and
  `build_purchase_album` maps it from the catalog track, closing a latent
  regression where every purchased track lost its edition.
- Downloads write tags, `cover.jpg`/`back.jpg` and goodies. All three are
  best-effort: the audio file is the deliverable and is already on disk and
  registered before any of it runs.
- `goodies` is deserialized leniently. Its populated shape has never been
  observed — it comes back empty on every album nobody owns — and `/album/get`
  is parsed with the strict structs, so an unexpected shape would take the whole
  album down rather than merely hide a booklet.

Riding along, both found while building the above and both unrelated to
Purchases:

- The metadata EDITOR silently discarded saves. `write_album_tags_to_files`
  picked its write target as "the primary tag, or else the first one I can find",
  and lofty refuses to WRITE a tag type a container only supports READING —
  silently, skipping it rather than erroring. A FLAC carrying an ID3v2 tag has no
  Vorbis comments, so every edited field went into that ID3v2 tag, the save
  dropped the lot, and the call returned Ok. The user pressed Save, saw a success
  toast, and the file never changed. Same defect, same fix, in the new purchase
  tagger.
- `fetch_asset_bytes` does not reuse the audio downloader. That one sizes its
  buffer from the response's own Content-Length and sets no total timeout, which
  is fine for a track from a trusted CDN and wrong for an arbitrary goodie URL on
  a machine that hard-freezes under memory pressure.

Tests: 277 across the four crates, covering every acceptance item the contract
set. The signature preimage character for character, the pagination loop
including the zero-total case, the sanitizer against the inputs that used to
panic, every deserializer default, and the wire envelopes against real bytes
captured from a live account. A download harness stands up a local HTTP server
and drives the pipeline end to end — real bytes over a real socket, a real FLAC
tagged and read back, a real database — and the two tag regressions were both
verified to FAIL against the old code before being accepted.

cc
2026-08-16 11:53:37 -06:00

385 lines
12 KiB
Rust

//! 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 BYTES (leaving room for extension and path).
//
// The cap is deliberately in bytes, not chars: it is what the shipped
// implementation used, and these strings become on-disk path segments that
// the library-to-registry join matches by EXACT string equality
// (`database.rs`, the `qobuz_purchase` source stamp). Recomputing the cap in
// chars would silently relocate every already-downloaded non-ASCII album.
//
// `String::truncate` panics when the index is not a char boundary, and that
// is reachable here rather than theoretical: the mapping above keeps every
// Unicode ALPHANUMERIC (`c.is_alphanumeric()` is Unicode-aware), so accented
// Latin, Greek, Cyrillic and CJK titles all survive as multibyte characters.
// A 200-byte cut lands mid-character for any such title long enough to hit
// it. Walk back to the nearest boundary instead.
if sanitized.len() > 200 {
let end = (0..=200)
.rev()
.find(|&i| sanitized.is_char_boundary(i))
.unwrap_or(0);
sanitized.truncate(end);
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())
}