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:
1 parent
384459d4d7
commit
d6e4b3349b
14 files changed
+201
-86
No files matched your search
@@ -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"] }
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user