feat(qbz-library): extract local library management to standalone crate

Create qbz-library crate with:
- LibraryScanner: directory scanning for audio files
- MetadataExtractor: audio metadata extraction via lofty
- LibraryDatabase: SQLite persistence with WAL mode
- CueParser: CUE sheet parsing for single-file albums
- Thumbnails: artwork extraction and thumbnail generation
- Tag sidecar: album metadata override files

src-tauri re-exports from qbz-library for backwards compatibility.
Enables TUI/headless clients to manage local libraries without Tauri.
This commit is contained in:
vicrodh committed 2026-02-15 12:12:52 -06:00
1 parent 384459d4d7
commit d6e4b3349b
14 files changed
+201 -86

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
[package]
name = "qbz-library"
version = "0.1.0"
edition = "2021"
description = "Local music library management for QBZ - scanning, indexing, metadata extraction"
authors = ["blitzkriegfc"]
[dependencies]
# Logging
log = "0.4"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Error handling
thiserror = "2"
# Database
rusqlite = { version = "0.31", features = ["bundled"] }
# Filesystem
walkdir = "2"
dirs = "5"
# Audio metadata
lofty = "0.18"
# Image processing (thumbnails)
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
# File system utilities
glob = "0.3"
# UUID generation
uuid = { version = "1", features = ["v4"] }
+313
View File
@@ -0,0 +1,313 @@
//! CUE sheet parser for single-file albums
use std::fs;
use std::path::Path;
use crate::{AudioFormat, AudioProperties, LibraryError, LocalTrack, MetadataExtractor};
/// Parsed CUE sheet
#[derive(Debug, Clone)]
pub struct CueSheet {
/// Path to the .cue file
pub file_path: String,
/// Referenced audio file (resolved to absolute path)
pub audio_file: String,
/// Album title
pub title: Option<String>,
/// Album performer/artist
pub performer: Option<String>,
/// Tracks in the CUE sheet
pub tracks: Vec<CueTrack>,
}
/// A track within a CUE sheet
#[derive(Debug, Clone)]
pub struct CueTrack {
/// Track number
pub number: u32,
/// Track title
pub title: String,
/// Track performer (if different from album)
pub performer: Option<String>,
/// Start time in seconds
pub start_secs: f64,
}
/// CUE time format (MM:SS:FF where FF is frames, 75 frames per second)
#[derive(Debug, Clone, Copy)]
pub struct CueTime {
pub minutes: u32,
pub seconds: u32,
pub frames: u32,
}
impl CueTime {
/// Convert to seconds (frames are 1/75 second)
pub fn to_seconds(&self) -> f64 {
self.minutes as f64 * 60.0 + self.seconds as f64 + self.frames as f64 / 75.0
}
/// Parse "MM:SS:FF" format
pub fn parse(s: &str) -> Option<Self> {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 3 {
return None;
}
Some(CueTime {
minutes: parts[0].parse().ok()?,
seconds: parts[1].parse().ok()?,
frames: parts[2].parse().ok()?,
})
}
}
/// CUE sheet parser
pub struct CueParser;
impl CueParser {
/// Parse a CUE file
pub fn parse(cue_path: &Path) -> Result<CueSheet, LibraryError> {
log::debug!("Parsing CUE file: {}", cue_path.display());
// Try UTF-8 first, then fall back to Latin-1
let content = fs::read_to_string(cue_path).or_else(|_| {
let bytes = fs::read(cue_path)?;
Ok::<String, std::io::Error>(bytes.iter().map(|&b| b as char).collect())
})?;
Self::parse_content(&content, cue_path)
}
/// Parse CUE content
fn parse_content(content: &str, cue_path: &Path) -> Result<CueSheet, LibraryError> {
let mut sheet = CueSheet {
file_path: cue_path.to_string_lossy().to_string(),
audio_file: String::new(),
title: None,
performer: None,
tracks: Vec::new(),
};
let mut current_track: Option<CueTrack> = None;
let mut in_track = false;
for line in content.lines() {
let line = line.trim();
// Skip empty lines and comments
if line.is_empty() || line.starts_with("REM") {
continue;
}
// Parse FILE "name" TYPE
if line.to_uppercase().starts_with("FILE ") {
if let Some(filename) = Self::extract_quoted(line) {
// Resolve path relative to CUE file
if let Some(parent) = cue_path.parent() {
let audio_path = parent.join(&filename);
sheet.audio_file = audio_path.to_string_lossy().to_string();
} else {
sheet.audio_file = filename;
}
}
}
// Parse album-level TITLE (before any TRACK)
else if line.to_uppercase().starts_with("TITLE ") && !in_track {
sheet.title = Self::extract_quoted(line);
}
// Parse album-level PERFORMER (before any TRACK)
else if line.to_uppercase().starts_with("PERFORMER ") && !in_track {
sheet.performer = Self::extract_quoted(line);
}
// Parse TRACK NN AUDIO
else if line.to_uppercase().starts_with("TRACK ") {
// Save previous track
if let Some(track) = current_track.take() {
sheet.tracks.push(track);
}
// Start new track
in_track = true;
if let Some(num) = Self::extract_track_number(line) {
current_track = Some(CueTrack {
number: num,
title: format!("Track {}", num),
performer: None,
start_secs: 0.0,
});
}
}
// Parse track TITLE
else if line.to_uppercase().starts_with("TITLE ") && in_track {
if let Some(ref mut track) = current_track {
if let Some(title) = Self::extract_quoted(line) {
track.title = title;
}
}
}
// Parse track PERFORMER
else if line.to_uppercase().starts_with("PERFORMER ") && in_track {
if let Some(ref mut track) = current_track {
track.performer = Self::extract_quoted(line);
}
}
// Parse INDEX 01 MM:SS:FF (track start time)
else if line.to_uppercase().starts_with("INDEX 01 ") {
if let Some(ref mut track) = current_track {
let time_str = line.get(9..).map(|s| s.trim()).unwrap_or("");
if let Some(time) = CueTime::parse(time_str) {
track.start_secs = time.to_seconds();
}
}
}
}
// Don't forget the last track
if let Some(track) = current_track {
sheet.tracks.push(track);
}
// Validate we have an audio file and at least one track
if sheet.audio_file.is_empty() {
return Err(LibraryError::CueParse(
"No FILE directive found in CUE sheet".to_string(),
));
}
if sheet.tracks.is_empty() {
return Err(LibraryError::CueParse(
"No tracks found in CUE sheet".to_string(),
));
}
log::info!(
"Parsed CUE: {} tracks, audio file: {}",
sheet.tracks.len(),
sheet.audio_file
);
Ok(sheet)
}
/// Extract quoted string: COMMAND "value" -> value
fn extract_quoted(line: &str) -> Option<String> {
let start = line.find('"')?;
let end = line.rfind('"')?;
if end <= start {
return None;
}
Some(line[start + 1..end].to_string())
}
/// Extract track number: "TRACK 01 AUDIO" -> 1
fn extract_track_number(line: &str) -> Option<u32> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 2 {
return None;
}
parts[1].parse().ok()
}
}
/// Convert a CUE sheet into LocalTrack entries
pub fn cue_to_tracks(
cue: &CueSheet,
audio_duration_secs: u64,
format: AudioFormat,
properties: &AudioProperties,
) -> Vec<LocalTrack> {
let mut tracks = Vec::new();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let cue_audio_path = Path::new(&cue.audio_file);
let (album_group_key, album_group_title) =
MetadataExtractor::album_group_info(cue_audio_path, cue.title.as_deref());
let inferred_disc = MetadataExtractor::infer_disc_number(cue_audio_path);
for (i, cue_track) in cue.tracks.iter().enumerate() {
// Calculate end time (next track's start or audio end)
let end_secs = if i + 1 < cue.tracks.len() {
cue.tracks[i + 1].start_secs
} else {
audio_duration_secs as f64
};
let duration = (end_secs - cue_track.start_secs).max(0.0) as u64;
tracks.push(LocalTrack {
id: 0,
file_path: cue.audio_file.clone(),
title: cue_track.title.clone(),
artist: cue_track
.performer
.clone()
.or_else(|| cue.performer.clone())
.unwrap_or_else(|| "Unknown Artist".to_string()),
album: cue
.title
.clone()
.unwrap_or_else(|| "Unknown Album".to_string()),
album_artist: cue.performer.clone(),
album_group_key: album_group_key.clone(),
album_group_title: album_group_title.clone(),
track_number: Some(cue_track.number),
disc_number: inferred_disc,
year: None,
genre: None,
catalog_number: None,
duration_secs: duration,
format: format.clone(),
bit_depth: properties.bit_depth,
sample_rate: properties.sample_rate,
channels: properties.channels,
file_size_bytes: 0,
cue_file_path: Some(cue.file_path.clone()),
cue_start_secs: Some(cue_track.start_secs),
cue_end_secs: Some(end_secs),
artwork_path: None,
last_modified: 0,
indexed_at: now,
source: None,
qobuz_track_id: None,
});
}
tracks
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cue_time_parse() {
let time = CueTime::parse("03:45:22").unwrap();
assert_eq!(time.minutes, 3);
assert_eq!(time.seconds, 45);
assert_eq!(time.frames, 22);
let secs = time.to_seconds();
assert!((secs - 225.293).abs() < 0.01);
}
#[test]
fn test_extract_quoted() {
assert_eq!(
CueParser::extract_quoted("TITLE \"My Song\""),
Some("My Song".to_string())
);
assert_eq!(
CueParser::extract_quoted("FILE \"album.flac\" WAVE"),
Some("album.flac".to_string())
);
}
#[test]
fn test_extract_track_number() {
assert_eq!(CueParser::extract_track_number("TRACK 01 AUDIO"), Some(1));
assert_eq!(CueParser::extract_track_number("TRACK 12 AUDIO"), Some(12));
}
}
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
//! Library error types
use thiserror::Error;
#[derive(Error, Debug)]
pub enum LibraryError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Database error: {0}")]
Database(String),
#[error("Metadata error: {0}")]
Metadata(String),
#[error("CUE parse error: {0}")]
CueParse(String),
#[error("Invalid path: {0}")]
InvalidPath(String),
#[error("{0}")]
Other(String),
}
+83
View File
@@ -0,0 +1,83 @@
//! QBZ Library - Local music library management
//!
//! Provides functionality for scanning, indexing, and managing local audio files.
//! This crate is completely independent of Tauri and the Qobuz streaming functionality.
//!
//! ## Features
//!
//! - **Scanner**: Recursive directory scanning for audio files
//! - **Metadata**: Audio metadata extraction using lofty
//! - **Database**: SQLite persistence for library data
//! - **CUE Parser**: Support for CUE sheet single-file albums
//! - **Thumbnails**: Artwork extraction and thumbnail generation
//!
//! ## Usage
//!
//! ```no_run
//! use qbz_library::{LibraryScanner, MetadataExtractor, LibraryDatabase};
//! use std::path::Path;
//!
//! // Scan a directory for audio files
//! let scanner = LibraryScanner::new();
//! let result = scanner.scan_directory(Path::new("/path/to/music")).unwrap();
//!
//! // Extract metadata from a file
//! let track = MetadataExtractor::extract(&result.audio_files[0]).unwrap();
//!
//! // Open library database
//! let db = LibraryDatabase::open(Path::new("library.db")).unwrap();
//! ```
mod errors;
mod models;
mod scanner;
mod thumbnails;
mod metadata;
mod cue_parser;
mod database;
mod tag_sidecar;
// Re-exports
pub use errors::LibraryError;
pub use models::*;
pub use scanner::{LibraryScanner, ScanResult};
pub use thumbnails::{
generate_thumbnail, generate_thumbnail_from_bytes,
get_thumbnail_path, get_thumbnails_dir, thumbnail_exists,
get_or_generate_thumbnail, clear_thumbnails, get_cache_size,
};
pub use metadata::MetadataExtractor;
pub use cue_parser::{CueParser, CueSheet, CueTrack, CueTime, cue_to_tracks};
pub use database::{
LibraryDatabase, LibraryFolder, LibraryStats,
AlbumTrackUpdate, TrackMetadataUpdateFull,
PlaylistFolder, PlaylistSettings, PlaylistStats,
LocalContentStatus,
};
// Re-export database module for backwards compatibility
pub mod database_exports {
pub use crate::database::*;
}
pub use tag_sidecar::*;
use std::path::PathBuf;
/// Get library database path in app data directory
pub fn get_db_path() -> PathBuf {
let data_dir = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("qbz");
std::fs::create_dir_all(&data_dir).ok();
data_dir.join("library.db")
}
/// Get artwork cache directory
pub fn get_artwork_cache_dir() -> PathBuf {
let cache_dir = dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("qbz")
.join("artwork");
std::fs::create_dir_all(&cache_dir).ok();
cache_dir
}
+634
View File
@@ -0,0 +1,634 @@
//! Metadata extraction for audio files
use lofty::{Accessor, AudioFile, ItemKey, Probe, TaggedFileExt};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::{AudioFormat, AudioProperties, LibraryError, LocalTrack};
use crate::thumbnails::{generate_thumbnail, generate_thumbnail_from_bytes};
/// Metadata extractor using lofty
pub struct MetadataExtractor;
impl MetadataExtractor {
fn normalize_field(value: Option<&str>) -> Option<String> {
value
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
fn strip_year_suffix(name: &str) -> String {
let trimmed = name.trim();
for (open, close) in [("(", ")"), ("[", "]")] {
if trimmed.ends_with(close) {
if let Some(start) = trimmed.rfind(open) {
let inside = &trimmed[start + 1..trimmed.len() - 1];
if inside.len() == 4 && inside.chars().all(|c| c.is_ascii_digit()) {
return trimmed[..start].trim().to_string();
}
}
}
}
trimmed.to_string()
}
fn strip_disc_suffix(title: &str) -> String {
let trimmed = title.trim();
for (open, close) in [("(", ")"), ("[", "]")] {
if trimmed.ends_with(close) {
if let Some(start) = trimmed.rfind(open) {
let inside = trimmed[start + 1..trimmed.len() - 1].trim();
if Self::is_disc_designator(inside) {
return trimmed[..start].trim().to_string();
}
}
}
}
let tokens: Vec<&str> = trimmed
.split_whitespace()
.filter(|token| *token != "-" && *token != "–" && *token != "—")
.collect();
if tokens.len() >= 2 {
let last = tokens[tokens.len() - 1];
let prev = tokens[tokens.len() - 2];
if Self::is_disc_marker(prev) && last.chars().all(|c| c.is_ascii_digit()) {
return tokens[..tokens.len() - 2].join(" ").trim().to_string();
}
}
if let Some(last) = tokens.last() {
if Self::is_disc_designator(last) {
if tokens.len() > 1 {
return tokens[..tokens.len() - 1].join(" ").trim().to_string();
}
}
}
trimmed.to_string()
}
fn is_disc_marker(value: &str) -> bool {
matches!(value.to_lowercase().as_str(), "disc" | "disk" | "cd")
}
fn is_disc_designator(value: &str) -> bool {
let cleaned: String = value
.to_lowercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect();
if cleaned.starts_with("disc") {
let rest = &cleaned[4..];
return !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit());
}
if cleaned.starts_with("disk") {
let rest = &cleaned[4..];
return !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit());
}
if cleaned.starts_with("cd") {
let rest = &cleaned[2..];
return !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit());
}
false
}
fn is_disc_folder(name: &str) -> bool {
let lower = name.to_lowercase();
let tokens: Vec<&str> = lower
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|t| !t.is_empty())
.collect();
for (i, token) in tokens.iter().enumerate() {
let has_digits = token.chars().any(|c| c.is_ascii_digit());
if (*token == "disc" || *token == "disk" || *token == "cd") && (has_digits || tokens.get(i + 1).map_or(false, |t| t.chars().all(|c| c.is_ascii_digit()))) {
return true;
}
if token.starts_with("disc") {
let rest = &token[4..];
if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
return true;
}
}
if token.starts_with("disk") {
let rest = &token[4..];
if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
return true;
}
}
if token.starts_with("cd") {
let rest = &token[2..];
if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
return true;
}
}
if *token == "bonus" && tokens.get(i + 1).map_or(false, |t| *t == "disc" || *t == "disk" || *t == "cd") {
return true;
}
}
false
}
fn disc_number_from_name(name: &str) -> Option<u32> {
let lower = name.to_lowercase();
let tokens: Vec<&str> = lower
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|t| !t.is_empty())
.collect();
for (i, token) in tokens.iter().enumerate() {
if (*token == "disc" || *token == "disk" || *token == "cd")
&& tokens
.get(i + 1)
.map_or(false, |t| t.chars().all(|c| c.is_ascii_digit()))
{
if let Some(next) = tokens.get(i + 1) {
if let Ok(value) = next.parse::<u32>() {
if value > 0 {
return Some(value);
}
}
}
}
for prefix in ["disc", "disk", "cd"] {
if token.starts_with(prefix) {
let rest = &token[prefix.len()..];
if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
if let Ok(value) = rest.parse::<u32>() {
if value > 0 {
return Some(value);
}
}
}
}
}
}
None
}
pub fn infer_disc_number(file_path: &Path) -> Option<u32> {
let parent_dir = file_path.parent()?;
let parent_name = parent_dir.file_name()?.to_str()?;
if !Self::is_disc_folder(parent_name) {
return None;
}
Self::disc_number_from_name(parent_name)
}
fn album_root_dir(file_path: &Path) -> Option<PathBuf> {
let parent_dir = file_path.parent()?;
let parent_name = parent_dir.file_name().and_then(|s| s.to_str());
if let Some(name) = parent_name {
if Self::is_disc_folder(name) {
return parent_dir.parent().map(|p| p.to_path_buf());
}
}
Some(parent_dir.to_path_buf())
}
fn infer_artist_album(file_path: &Path) -> (Option<String>, Option<String>) {
let album_dir = Self::album_root_dir(file_path);
let album_name = album_dir
.as_ref()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.map(Self::strip_year_suffix);
let artist_name = album_dir
.as_ref()
.and_then(|p| p.parent())
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.map(Self::strip_year_suffix);
if artist_name.is_none() {
if let Some(album_dir_name) = album_name.as_deref() {
if let Some((artist, album)) = album_dir_name.split_once(" - ") {
return (
Some(Self::strip_year_suffix(artist)),
Some(Self::strip_year_suffix(album)),
);
}
}
}
(artist_name, album_name)
}
pub fn album_group_info(file_path: &Path, tag_album: Option<&str>) -> (String, String) {
let album_dir = Self::album_root_dir(file_path);
let group_key = album_dir
.as_ref()
.map(|dir| dir.to_string_lossy().to_string())
.unwrap_or_else(|| file_path.to_string_lossy().to_string());
let mut group_title = tag_album
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.filter(|value| !value.eq_ignore_ascii_case("Unknown Album"))
.map(|value| value.to_string())
.or_else(|| {
album_dir
.as_ref()
.and_then(|dir| dir.file_name())
.and_then(|s| s.to_str())
.map(Self::strip_year_suffix)
})
.unwrap_or_else(|| "Unknown Album".to_string());
group_title = Self::strip_disc_suffix(&group_title);
(group_key, group_title)
}
/// Extract metadata from an audio file
pub fn extract(file_path: &Path) -> Result<LocalTrack, LibraryError> {
log::debug!("Extracting metadata from: {}", file_path.display());
// Probe the file
let tagged_file = Probe::open(file_path)
.map_err(|e| LibraryError::Metadata(format!("Failed to open file: {}", e)))?
.read()
.map_err(|e| LibraryError::Metadata(format!("Failed to read file: {}", e)))?;
// Get the primary tag (prefer ID3v2/Vorbis/APE)
let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag());
// Get audio properties
let properties = tagged_file.properties();
let duration_secs = properties.duration().as_secs();
let sample_rate = properties.sample_rate().unwrap_or(44100) as f64;
let bit_depth = properties.bit_depth().map(|b| b as u32);
let channels = properties.channels().unwrap_or(2) as u8;
// Get file metadata
let file_metadata = fs::metadata(file_path).map_err(LibraryError::Io)?;
let file_size_bytes = file_metadata.len();
let last_modified = file_metadata
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
// Detect format
let format = Self::detect_format(file_path);
// Get filename for fallback title
let filename = file_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown")
.to_string();
let (fallback_artist, fallback_album) = Self::infer_artist_album(file_path);
let inferred_disc = Self::infer_disc_number(file_path);
// Build track
let track = if let Some(tag) = tag {
let album_title = Self::normalize_field(tag.album().as_deref())
.or_else(|| fallback_album.clone())
.unwrap_or_else(|| "Unknown Album".to_string());
let (album_group_key, album_group_title) =
Self::album_group_info(file_path, Some(album_title.as_str()));
LocalTrack {
id: 0,
file_path: file_path.to_string_lossy().to_string(),
title: tag
.title()
.map(|s| s.to_string())
.unwrap_or(filename),
artist: Self::normalize_field(tag.artist().as_deref())
.or_else(|| fallback_artist.clone())
.unwrap_or_else(|| "Unknown Artist".to_string()),
album: album_title,
album_artist: tag.get_string(&ItemKey::AlbumArtist).map(|s| s.to_string()),
album_group_key,
album_group_title,
track_number: tag.track().map(|t| t as u32),
disc_number: tag
.disk()
.and_then(|d| if d > 0 { Some(d as u32) } else { None })
.or(inferred_disc),
year: tag.year().map(|y| y as u32),
genre: tag.genre().map(|s| s.to_string()),
catalog_number: tag.get_string(&ItemKey::CatalogNumber).map(|s| s.to_string()),
duration_secs,
format,
bit_depth,
sample_rate,
channels,
file_size_bytes,
cue_file_path: None,
cue_start_secs: None,
cue_end_secs: None,
artwork_path: None,
last_modified,
indexed_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0),
source: None,
qobuz_track_id: None,
}
} else {
// No tag found, use defaults
let album_title = fallback_album
.clone()
.unwrap_or_else(|| "Unknown Album".to_string());
let (album_group_key, album_group_title) =
Self::album_group_info(file_path, Some(album_title.as_str()));
LocalTrack {
id: 0,
file_path: file_path.to_string_lossy().to_string(),
title: filename,
artist: fallback_artist
.unwrap_or_else(|| "Unknown Artist".to_string()),
album: album_title,
album_artist: None,
album_group_key,
album_group_title,
track_number: None,
disc_number: inferred_disc,
year: None,
genre: None,
catalog_number: None,
duration_secs,
format,
bit_depth,
sample_rate,
channels,
file_size_bytes,
cue_file_path: None,
cue_start_secs: None,
cue_end_secs: None,
artwork_path: None,
last_modified,
indexed_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0),
source: None,
qobuz_track_id: None,
}
};
Ok(track)
}
/// Extract audio properties without full metadata
pub fn extract_properties(file_path: &Path) -> Result<AudioProperties, LibraryError> {
let tagged_file = Probe::open(file_path)
.map_err(|e| LibraryError::Metadata(format!("Failed to open file: {}", e)))?
.read()
.map_err(|e| LibraryError::Metadata(format!("Failed to read file: {}", e)))?;
let properties = tagged_file.properties();
Ok(AudioProperties {
duration_secs: properties.duration().as_secs(),
bit_depth: properties.bit_depth().map(|b| b as u32),
sample_rate: properties.sample_rate().unwrap_or(44100) as f64,
channels: properties.channels().unwrap_or(2) as u8,
})
}
/// Determine AudioFormat from file extension
pub fn detect_format(path: &Path) -> AudioFormat {
match path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase())
.as_deref()
{
Some("flac") => AudioFormat::Flac,
Some("m4a") => AudioFormat::Alac,
Some("wav") => AudioFormat::Wav,
Some("aiff") | Some("aif") => AudioFormat::Aiff,
Some("ape") => AudioFormat::Ape,
Some("mp3") => AudioFormat::Mp3,
_ => AudioFormat::Unknown,
}
}
/// Extract and save artwork as thumbnail to cache directory
pub fn extract_artwork(file_path: &Path, _cache_dir: &Path) -> Option<String> {
let tagged_file = Probe::open(file_path).ok()?.read().ok()?;
let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag())?;
let picture = tag.pictures().first()?;
let cache_key = file_path.to_string_lossy().to_string();
match generate_thumbnail_from_bytes(picture.data(), &cache_key) {
Ok(thumbnail_path) => Some(thumbnail_path.to_string_lossy().to_string()),
Err(e) => {
log::warn!("Failed to generate thumbnail for {:?}: {}", file_path, e);
None
}
}
}
/// Generate thumbnail from an existing artwork file
pub fn cache_artwork_file(artwork_path: &Path, _cache_dir: &Path) -> Option<String> {
if !artwork_path.is_file() {
return None;
}
match generate_thumbnail(artwork_path) {
Ok(thumbnail_path) => Some(thumbnail_path.to_string_lossy().to_string()),
Err(e) => {
log::warn!("Failed to generate thumbnail for {:?}: {}", artwork_path, e);
None
}
}
}
/// Look for folder artwork by file name heuristics
pub fn find_folder_artwork(
audio_file_path: &Path,
album_title: Option<&str>,
) -> Option<String> {
let parent_dir = audio_file_path.parent()?;
let album_dir =
Self::album_root_dir(audio_file_path).unwrap_or_else(|| parent_dir.to_path_buf());
let mut dirs_to_check: Vec<PathBuf> = Vec::new();
if album_dir != parent_dir {
dirs_to_check.push(album_dir.clone());
}
dirs_to_check.push(parent_dir.to_path_buf());
let album_key = album_title
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.filter(|value| !value.eq_ignore_ascii_case("Unknown Album"))
.map(Self::strip_disc_suffix)
.and_then(|value| Self::normalize_artwork_key(&value));
let folder_key = album_dir
.file_name()
.and_then(|s| s.to_str())
.map(Self::strip_disc_suffix)
.and_then(|value| Self::normalize_artwork_key(&value));
let mut best: Option<(PathBuf, i32)> = None;
let mut best_score = 0;
let mut candidate_count = 0;
for (index, dir) in dirs_to_check.iter().enumerate() {
let dir_bonus = if index == 0 { 5 } else { 0 };
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if !Self::is_supported_artwork_ext(&ext) {
continue;
}
candidate_count += 1;
let file_stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.trim();
let file_key = match Self::normalize_artwork_key(file_stem) {
Some(key) => key,
None => {
let fallback = file_stem.to_lowercase();
if fallback.trim().is_empty() {
continue;
}
fallback
}
};
let mut score = Self::artwork_score(
&file_key,
album_key.as_deref(),
folder_key.as_deref(),
);
if score == 0 {
score = 5;
}
score += dir_bonus;
if score > best_score {
best_score = score;
best = Some((path, score));
}
}
}
if let Some((path, score)) = best {
if score >= 10 || candidate_count == 1 {
return Some(path.to_string_lossy().to_string());
}
}
None
}
fn normalize_artwork_key(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let normalized: String = trimmed
.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric())
.collect();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
fn is_supported_artwork_ext(ext: &str) -> bool {
matches!(ext, "jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp")
}
fn artwork_score(
file_key: &str,
album_key: Option<&str>,
folder_key: Option<&str>,
) -> i32 {
const EXACT: &[&str] = &["cover", "folder", "front", "album", "artwork", "art"];
let mut score = 0;
if EXACT.iter().any(|name| *name == file_key) {
score = score.max(100);
}
if let Some(key) = album_key {
if file_key == key {
score = score.max(95);
} else if file_key.contains(key) {
score = score.max(70);
}
}
if let Some(key) = folder_key {
if file_key == key {
score = score.max(90);
} else if file_key.contains(key) {
score = score.max(65);
}
}
if EXACT.iter().any(|name| file_key.contains(name)) {
score = score.max(80);
}
score
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_format() {
assert_eq!(
MetadataExtractor::detect_format(Path::new("test.flac")),
AudioFormat::Flac
);
assert_eq!(
MetadataExtractor::detect_format(Path::new("test.m4a")),
AudioFormat::Alac
);
assert_eq!(
MetadataExtractor::detect_format(Path::new("test.wav")),
AudioFormat::Wav
);
assert_eq!(
MetadataExtractor::detect_format(Path::new("test.mp3")),
AudioFormat::Mp3
);
}
}
+241
View File
@@ -0,0 +1,241 @@
//! Data models for local library
use serde::{Deserialize, Serialize};
/// Supported audio formats
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum AudioFormat {
Flac,
Alac,
Wav,
Aiff,
Ape,
Mp3,
Unknown,
}
impl Default for AudioFormat {
fn default() -> Self {
Self::Unknown
}
}
impl std::fmt::Display for AudioFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AudioFormat::Flac => write!(f, "FLAC"),
AudioFormat::Alac => write!(f, "ALAC"),
AudioFormat::Wav => write!(f, "WAV"),
AudioFormat::Aiff => write!(f, "AIFF"),
AudioFormat::Ape => write!(f, "APE"),
AudioFormat::Mp3 => write!(f, "MP3"),
AudioFormat::Unknown => write!(f, "Unknown"),
}
}
}
/// A track from the local library
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalTrack {
pub id: i64,
pub file_path: String,
// Metadata
pub title: String,
pub artist: String,
pub album: String,
pub album_artist: Option<String>,
pub album_group_key: String,
pub album_group_title: String,
pub track_number: Option<u32>,
pub disc_number: Option<u32>,
pub year: Option<u32>,
pub genre: Option<String>,
pub catalog_number: Option<String>,
// Audio properties
pub duration_secs: u64,
pub format: AudioFormat,
pub bit_depth: Option<u32>,
pub sample_rate: f64, // Changed from u32 to f64 to support fractional rates (44.1kHz = 44100Hz)
pub channels: u8,
pub file_size_bytes: u64,
// CUE support
pub cue_file_path: Option<String>,
pub cue_start_secs: Option<f64>,
pub cue_end_secs: Option<f64>,
// Artwork
pub artwork_path: Option<String>,
// Indexing
pub last_modified: i64,
pub indexed_at: i64,
// Download tracking
pub source: Option<String>,
pub qobuz_track_id: Option<i64>,
}
impl Default for LocalTrack {
fn default() -> Self {
Self {
id: 0,
file_path: String::new(),
title: String::new(),
artist: "Unknown Artist".to_string(),
album: "Unknown Album".to_string(),
album_artist: None,
album_group_key: String::new(),
album_group_title: String::new(),
track_number: None,
disc_number: None,
year: None,
genre: None,
catalog_number: None,
duration_secs: 0,
format: AudioFormat::Unknown,
bit_depth: None,
sample_rate: 44100.0, // Now f64
channels: 2,
file_size_bytes: 0,
cue_file_path: None,
cue_start_secs: None,
cue_end_secs: None,
artwork_path: None,
last_modified: 0,
indexed_at: 0,
source: None,
qobuz_track_id: None,
}
}
}
/// A local track within a playlist, including its position
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaylistLocalTrack {
#[serde(flatten)]
pub track: LocalTrack,
/// Position in the combined playlist (Qobuz + local tracks)
pub playlist_position: i32,
}
/// An album aggregated from local tracks
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalAlbum {
pub id: String,
pub title: String,
pub artist: String,
/// All contributing artists (comma-separated) - used for matching in artist view
/// This includes all unique album_artist/artist values from the album's tracks
#[serde(default)]
pub all_artists: String,
pub year: Option<u32>,
pub catalog_number: Option<String>,
pub artwork_path: Option<String>,
pub track_count: u32,
pub total_duration_secs: u64,
pub format: AudioFormat,
pub bit_depth: Option<u32>,
pub sample_rate: f64, // Changed from u32 to f64 for decimal precision
pub directory_path: String,
/// Source of the album: "user" for local files, "qobuz_download" for offline cached
#[serde(default = "default_source")]
pub source: String,
}
fn default_source() -> String {
"user".to_string()
}
/// An artist aggregated from local tracks
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalArtist {
pub name: String,
pub album_count: u32,
pub track_count: u32,
}
/// Scan progress for UI updates
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanProgress {
pub status: ScanStatus,
pub total_files: u32,
pub processed_files: u32,
pub current_file: Option<String>,
pub errors: Vec<ScanError>,
}
impl Default for ScanProgress {
fn default() -> Self {
Self {
status: ScanStatus::Idle,
total_files: 0,
processed_files: 0,
current_file: None,
errors: Vec::new(),
}
}
}
/// Scan status
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ScanStatus {
Idle,
Scanning,
Complete,
Cancelled,
Error,
}
/// A scan error for a specific file
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanError {
pub file_path: String,
pub error: String,
}
/// Audio properties extracted from a file
#[derive(Debug, Clone, Default)]
pub struct AudioProperties {
pub duration_secs: u64,
pub bit_depth: Option<u32>,
pub sample_rate: f64, // Changed from u32 to f64 for decimal precision
pub channels: u8,
}
/// Album settings for local library albums
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlbumSettings {
pub album_group_key: String,
pub hidden: bool,
pub created_at: i64,
pub updated_at: i64,
}
impl AlbumSettings {
pub fn new(album_group_key: String) -> Self {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
Self {
album_group_key,
hidden: false,
created_at: now,
updated_at: now,
}
}
}
/// Information about an artist's image
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtistImageInfo {
pub artist_name: String,
pub image_url: Option<String>,
pub source: Option<String>,
pub custom_image_path: Option<String>,
pub canonical_name: Option<String>,
}
+111
View File
@@ -0,0 +1,111 @@
//! Filesystem scanner for audio files
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use crate::LibraryError;
/// Supported audio file extensions
const SUPPORTED_AUDIO_EXTENSIONS: &[&str] = &["flac", "m4a", "wav", "aiff", "aif", "ape", "mp3"];
/// CUE file extension
const CUE_EXTENSION: &str = "cue";
/// Result of scanning a directory
#[derive(Debug, Default)]
pub struct ScanResult {
/// Audio files found
pub audio_files: Vec<PathBuf>,
/// CUE files found
pub cue_files: Vec<PathBuf>,
}
/// Library scanner for discovering audio files
pub struct LibraryScanner;
impl LibraryScanner {
/// Create a new scanner
pub fn new() -> Self {
Self
}
/// Scan a directory recursively for audio and CUE files
pub fn scan_directory(&self, path: &Path) -> Result<ScanResult, LibraryError> {
if !path.exists() {
return Err(LibraryError::InvalidPath(format!(
"Path does not exist: {}",
path.display()
)));
}
if !path.is_dir() {
return Err(LibraryError::InvalidPath(format!(
"Path is not a directory: {}",
path.display()
)));
}
let mut result = ScanResult::default();
for entry in WalkDir::new(path)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let ext_lower = ext.to_lowercase();
if Self::is_supported_audio_extension(&ext_lower) {
result.audio_files.push(path.to_path_buf());
} else if ext_lower == CUE_EXTENSION {
result.cue_files.push(path.to_path_buf());
}
}
}
log::info!(
"Scanned {}: found {} audio files, {} CUE files",
path.display(),
result.audio_files.len(),
result.cue_files.len()
);
Ok(result)
}
/// Check if an extension is a supported audio format
fn is_supported_audio_extension(ext: &str) -> bool {
SUPPORTED_AUDIO_EXTENSIONS.contains(&ext)
}
/// Get all supported extensions (for UI display)
pub fn supported_extensions() -> &'static [&'static str] {
SUPPORTED_AUDIO_EXTENSIONS
}
}
impl Default for LibraryScanner {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_supported_extensions() {
assert!(LibraryScanner::is_supported_audio_extension("flac"));
assert!(LibraryScanner::is_supported_audio_extension("wav"));
assert!(LibraryScanner::is_supported_audio_extension("m4a"));
assert!(LibraryScanner::is_supported_audio_extension("mp3"));
assert!(!LibraryScanner::is_supported_audio_extension("txt"));
}
}
+152
View File
@@ -0,0 +1,152 @@
//! LocalLibrary album tag sidecar support.
//!
//! Sidecar files live next to album folders (default `.qbz.json`) and store
//! album-level + per-track metadata overrides.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::{LibraryError, LocalTrack};
const SIDECAR_FILE_NAME: &str = ".qbz.json";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AlbumMetadataOverride {
pub album_title: Option<String>,
pub album_artist: Option<String>,
pub year: Option<u32>,
pub genre: Option<String>,
pub catalog_number: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrackMetadataOverride {
pub file_path: String,
pub cue_start_secs: Option<f64>,
pub title: Option<String>,
pub disc_number: Option<u32>,
pub track_number: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AlbumTagSidecar {
pub version: u32,
pub updated_at: i64,
pub album: AlbumMetadataOverride,
pub tracks: Vec<TrackMetadataOverride>,
}
impl AlbumTagSidecar {
pub fn new(album: AlbumMetadataOverride, tracks: Vec<TrackMetadataOverride>) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
Self {
version: 1,
updated_at: now,
album,
tracks,
}
}
}
pub fn sidecar_path(album_dir: &Path) -> PathBuf {
album_dir.join(SIDECAR_FILE_NAME)
}
pub fn read_album_sidecar(album_dir: &Path) -> Result<Option<AlbumTagSidecar>, LibraryError> {
let path = sidecar_path(album_dir);
if !path.exists() {
return Ok(None);
}
let bytes = fs::read(&path).map_err(LibraryError::Io)?;
let sidecar: AlbumTagSidecar =
serde_json::from_slice(&bytes).map_err(|e| LibraryError::Metadata(e.to_string()))?;
Ok(Some(sidecar))
}
pub fn write_album_sidecar(album_dir: &Path, sidecar: &AlbumTagSidecar) -> Result<(), LibraryError> {
fs::create_dir_all(album_dir).map_err(LibraryError::Io)?;
let target = sidecar_path(album_dir);
let tmp = album_dir.join(format!("{}.tmp", SIDECAR_FILE_NAME));
let content =
serde_json::to_vec_pretty(sidecar).map_err(|e| LibraryError::Metadata(e.to_string()))?;
fs::write(&tmp, content).map_err(LibraryError::Io)?;
fs::rename(&tmp, &target).map_err(LibraryError::Io)?;
Ok(())
}
pub fn delete_album_sidecar(album_dir: &Path) -> Result<(), LibraryError> {
let path = sidecar_path(album_dir);
if !path.exists() {
return Ok(());
}
fs::remove_file(&path).map_err(LibraryError::Io)?;
Ok(())
}
pub fn apply_sidecar_to_track(track: &mut LocalTrack, sidecar: &AlbumTagSidecar) {
if let Some(title) = sidecar.album.album_title.as_ref().and_then(|s| normalize(s)) {
track.album = title.clone();
track.album_group_title = title.clone();
}
if let Some(album_artist) = sidecar.album.album_artist.as_ref().and_then(|s| normalize(s)) {
track.album_artist = Some(album_artist.clone());
}
if let Some(year) = sidecar.album.year {
track.year = Some(year);
}
if let Some(genre) = sidecar.album.genre.as_ref().and_then(|s| normalize(s)) {
track.genre = Some(genre.clone());
}
if let Some(cat) = sidecar
.album
.catalog_number
.as_ref()
.and_then(|s| normalize(s))
{
track.catalog_number = Some(cat.clone());
}
if let Some(entry) = sidecar.tracks.iter().find(|t| {
t.file_path == track.file_path
&& match (t.cue_start_secs, track.cue_start_secs) {
(Some(a), Some(b)) => (a - b).abs() < 0.001,
(None, None) => true,
_ => false,
}
}) {
if let Some(title) = entry.title.as_ref().and_then(|s| normalize(s)) {
track.title = title.clone();
}
if let Some(disc) = entry.disc_number {
track.disc_number = Some(disc);
}
if let Some(no) = entry.track_number {
track.track_number = Some(no);
}
}
}
fn normalize(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
+166
View File
@@ -0,0 +1,166 @@
//! Thumbnail generation for album artwork
use image::imageops::FilterType;
use image::ImageReader;
use std::fs;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use crate::LibraryError;
/// Default thumbnail size (width and height)
/// 500px is a good balance for UI display while keeping file size reasonable
const THUMBNAIL_SIZE: u32 = 500;
/// Get the thumbnails directory path
pub fn get_thumbnails_dir() -> Result<PathBuf, LibraryError> {
let data_dir = dirs::data_local_dir()
.ok_or_else(|| LibraryError::Other("Could not find data directory".into()))?;
let thumbnails_dir = data_dir.join("qbz").join("thumbnails");
// Create directory if it doesn't exist
if !thumbnails_dir.exists() {
fs::create_dir_all(&thumbnails_dir)
.map_err(|e| LibraryError::Other(format!("Failed to create thumbnails directory: {}", e)))?;
}
Ok(thumbnails_dir)
}
/// Generate a unique filename for a thumbnail based on the source path
fn get_thumbnail_filename(source_path: &Path) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
source_path.to_string_lossy().hash(&mut hasher);
format!("{:016x}.jpg", hasher.finish())
}
/// Get the thumbnail path for a source image
pub fn get_thumbnail_path(source_path: &Path) -> Result<PathBuf, LibraryError> {
let thumbnails_dir = get_thumbnails_dir()?;
let filename = get_thumbnail_filename(source_path);
Ok(thumbnails_dir.join(filename))
}
/// Check if a thumbnail exists for the given source path
pub fn thumbnail_exists(source_path: &Path) -> Result<bool, LibraryError> {
let thumbnail_path = get_thumbnail_path(source_path)?;
Ok(thumbnail_path.exists())
}
/// Generate a thumbnail for the given source image
pub fn generate_thumbnail(source_path: &Path) -> Result<PathBuf, LibraryError> {
let thumbnail_path = get_thumbnail_path(source_path)?;
// If thumbnail already exists, return it
if thumbnail_path.exists() {
return Ok(thumbnail_path);
}
// Read source image
let img = ImageReader::open(source_path)
.map_err(|e| LibraryError::Other(format!("Failed to open image: {}", e)))?
.decode()
.map_err(|e| LibraryError::Other(format!("Failed to decode image: {}", e)))?;
// Resize to thumbnail size (maintaining aspect ratio, fit within square)
let thumbnail = img.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, FilterType::Lanczos3);
// Save as JPEG with quality 85
thumbnail
.save(&thumbnail_path)
.map_err(|e| LibraryError::Other(format!("Failed to save thumbnail: {}", e)))?;
Ok(thumbnail_path)
}
/// Generate a thumbnail from image bytes (for embedded artwork)
pub fn generate_thumbnail_from_bytes(
bytes: &[u8],
cache_key: &str,
) -> Result<PathBuf, LibraryError> {
let thumbnails_dir = get_thumbnails_dir()?;
// Generate filename from cache key
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
cache_key.hash(&mut hasher);
let filename = format!("{:016x}.jpg", hasher.finish());
let thumbnail_path = thumbnails_dir.join(&filename);
// If thumbnail already exists, return it
if thumbnail_path.exists() {
return Ok(thumbnail_path);
}
// Decode image from bytes
let cursor = Cursor::new(bytes);
let img = ImageReader::new(cursor)
.with_guessed_format()
.map_err(|e| LibraryError::Other(format!("Failed to guess image format: {}", e)))?
.decode()
.map_err(|e| LibraryError::Other(format!("Failed to decode image: {}", e)))?;
// Resize to thumbnail size
let thumbnail = img.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, FilterType::Lanczos3);
// Save as JPEG
thumbnail
.save(&thumbnail_path)
.map_err(|e| LibraryError::Other(format!("Failed to save thumbnail: {}", e)))?;
Ok(thumbnail_path)
}
/// Get or generate a thumbnail for an artwork path
/// Returns the path to the thumbnail file
pub fn get_or_generate_thumbnail(artwork_path: &Path) -> Result<PathBuf, LibraryError> {
let thumbnail_path = get_thumbnail_path(artwork_path)?;
if thumbnail_path.exists() {
return Ok(thumbnail_path);
}
generate_thumbnail(artwork_path)
}
/// Clear all thumbnails (useful for cache cleanup)
pub fn clear_thumbnails() -> Result<(), LibraryError> {
let thumbnails_dir = get_thumbnails_dir()?;
if thumbnails_dir.exists() {
fs::remove_dir_all(&thumbnails_dir)
.map_err(|e| LibraryError::Other(format!("Failed to clear thumbnails: {}", e)))?;
fs::create_dir_all(&thumbnails_dir)
.map_err(|e| LibraryError::Other(format!("Failed to recreate thumbnails directory: {}", e)))?;
}
Ok(())
}
/// Get the total size of the thumbnails cache in bytes
pub fn get_cache_size() -> Result<u64, LibraryError> {
let thumbnails_dir = get_thumbnails_dir()?;
if !thumbnails_dir.exists() {
return Ok(0);
}
let mut total_size = 0u64;
for entry in fs::read_dir(&thumbnails_dir)
.map_err(|e| LibraryError::Other(format!("Failed to read thumbnails directory: {}", e)))?
{
if let Ok(entry) = entry {
if let Ok(metadata) = entry.metadata() {
total_size += metadata.len();
}
}
}
Ok(total_size)
}