refactor(qbz-library): extract ephemeral-folder module to the shared crate

Lift the in-memory ephemeral-folder library (synthetic-id store + folder
scan / metadata / CUE handling) out of the Tauri binary into a
frontend-agnostic qbz-library module (ADR-006), so both the Tauri and Slint
frontends consume identical logic. src-tauri/ephemeral_library now re-exports
it as a thin shim. Adds tracks_snapshot() / current_folder_path() for the
Slint consumer.

cc
This commit is contained in:
vicrodh committed 2026-06-07 10:12:44 -06:00
1 parent d08c5f6569
commit ee9a3c1c89
3 files changed
+417 -394

No files matched your search

+410
View File
@@ -0,0 +1,410 @@
//! In-memory ephemeral library for ad-hoc folder playback.
//!
//! The user can point QBZ at a folder that lives outside their library
//! (a downloaded album they haven't decided to keep, an external drive,
//! etc.), browse it, and play tracks from it without anything landing
//! in `local_tracks`. The ephemeral session lives only in memory: a
//! `HashMap<i64, LocalTrack>` keyed by *synthetic ids in the high
//! range* (>= `EPHEMERAL_ID_FLOOR` = 2^48). Synthetic ids in this range
//! are how the rest of the playback pipeline distinguishes ephemeral
//! tracks from DB-resolvable ones — local_tracks autoincrement IDs are
//! orders of magnitude smaller, so any track_id arriving at
//! `v2_library_play_track` at or above the floor is unambiguously
//! ephemeral and gets routed here instead of the DB.
//!
//! The high-positive design (instead of the obvious "use negatives")
//! exists because the queue/playback-context commands serialize ids as
//! `u64` end-to-end (V2QueueTrack, v2_set_playback_context) and reject
//! negative numbers at the serde boundary. Positive ids above the DB
//! range and below 2^53 (JS Number safe limit) are valid u64 *and*
//! survive the JSON round-trip without precision loss.
//!
//! Only one folder is held at a time; opening a new folder replaces the
//! previous session. The state vanishes on app exit by virtue of being
//! in-memory — nothing persists, no migration, no cleanup logic needed.
//!
//! This module is frontend-agnostic (ADR-006): it has zero Tauri/Slint
//! dependency and is consumed by both frontends. The Tauri build re-exports
//! it via `src-tauri/src/ephemeral_library/mod.rs`; the Slint build wraps it
//! in a process-global singleton in `crates/qbz-slint/src/ephemeral.rs`.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::{cue_to_tracks, CueParser, LibraryError, LibraryScanner, LocalTrack, MetadataExtractor};
use serde::Serialize;
/// Floor for synthetic ephemeral track ids. Any id at or above this
/// value is an ephemeral track; below it is a DB row id. Set high
/// enough to be impossible to collide with autoincrement DB ids in any
/// realistic library size, low enough to fit in JS Number's safe
/// integer range (2^53 - 1) so the JSON round-trip stays lossless.
pub const EPHEMERAL_ID_FLOOR: i64 = 1 << 48;
#[derive(Debug, Serialize, Clone)]
pub struct EphemeralFolderResult {
pub folder_path: String,
pub tracks: Vec<LocalTrack>,
pub skipped_files: usize,
}
#[derive(Debug)]
pub enum EphemeralError {
Lock,
Library(String),
Io(String),
}
impl std::fmt::Display for EphemeralError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Lock => write!(f, "ephemeral library state lock poisoned"),
Self::Library(msg) => write!(f, "{}", msg),
Self::Io(msg) => write!(f, "{}", msg),
}
}
}
impl From<LibraryError> for EphemeralError {
fn from(e: LibraryError) -> Self {
EphemeralError::Library(e.to_string())
}
}
struct EphemeralLibraryInner {
tracks: HashMap<i64, LocalTrack>,
next_id: i64,
current_folder_path: Option<String>,
}
impl EphemeralLibraryInner {
fn new() -> Self {
Self {
tracks: HashMap::new(),
next_id: EPHEMERAL_ID_FLOOR,
current_folder_path: None,
}
}
fn reset(&mut self) {
self.tracks.clear();
self.next_id = EPHEMERAL_ID_FLOOR;
self.current_folder_path = None;
}
}
pub struct EphemeralLibraryState {
inner: Mutex<EphemeralLibraryInner>,
}
impl EphemeralLibraryState {
pub fn new() -> Self {
Self {
inner: Mutex::new(EphemeralLibraryInner::new()),
}
}
/// Scan a folder, extract metadata for every supported audio file
/// found, assign synthetic high ids and stash the result. The
/// previous ephemeral session, if any, is dropped.
pub fn open_folder(&self, path: &Path) -> Result<EphemeralFolderResult, EphemeralError> {
if !path.exists() {
return Err(EphemeralError::Io(format!(
"Folder does not exist: {}",
path.display()
)));
}
if !path.is_dir() {
return Err(EphemeralError::Io(format!(
"Not a directory: {}",
path.display()
)));
}
let scanner = LibraryScanner::new();
let scan = scanner.scan_directory(path)?;
let mut tracks_out: Vec<LocalTrack> = Vec::with_capacity(scan.audio_files.len());
let mut skipped_files: usize = 0;
let mut inner = self.inner.lock().map_err(|_| EphemeralError::Lock)?;
inner.reset();
// Cache directory for artwork thumbnails. Same one the regular
// index uses, so ephemeral artwork piggy-backs on the existing
// thumbnail pipeline (and gets evicted by the same housekeeping).
let artwork_cache = crate::get_artwork_cache_dir();
// Two artwork caches keyed at different granularities. The bigger
// win is the album-level cache: embedded covers are usually
// identical across every track of an album, so doing extract_artwork
// (Probe::open + thumbnail encode) 155 times for a 155-track album
// is wasted I/O. The folder-level cache is a smaller secondary
// saver for find_folder_artwork (cover.jpg lookup) when albums
// share the same parent directory.
let mut album_artwork_cache: HashMap<String, Option<String>> = HashMap::new();
let mut folder_artwork_cache: HashMap<PathBuf, Option<String>> = HashMap::new();
// Audio files referenced by CUE sheets. We index those audio files
// through the CUE path (one logical "album" file gets exploded
// into N virtual tracks) and skip them in the regular audio loop
// below — otherwise the user would see both the CUE-derived
// tracks and a single-row entry for the underlying FLAC/WAV.
let mut cue_referenced_audio: HashSet<PathBuf> = HashSet::new();
for cue_path in &scan.cue_files {
match CueParser::parse(cue_path) {
Ok(mut cue) => {
let audio_path_raw = Path::new(&cue.audio_file).to_path_buf();
let canonical = std::fs::canonicalize(&audio_path_raw)
.unwrap_or_else(|_| audio_path_raw.clone());
if !canonical.exists() {
log::warn!(
"[ephemeral] CUE references missing audio: {} -> {}",
cue_path.display(),
audio_path_raw.display()
);
skipped_files += 1;
continue;
}
cue.audio_file = canonical.to_string_lossy().to_string();
// The decoder behind play_data is Symphonia, which
// covers FLAC / MP3 / M4A (AAC + ALAC) / ALAC /
// WAV / AIFF out of the box (`features = ["all"]`).
// APE (Monkey's Audio) and raw BIN (CD-DA dumps
// without headers) aren't in that list: playback
// either errors out or produces white noise as
// Symphonia mis-probes the stream. Skip CUE files
// that point at those — better an empty pane than
// a track row that explodes on click.
let ext_lower = canonical
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase());
let playable_via_cue = matches!(
ext_lower.as_deref(),
Some("flac" | "mp3" | "m4a" | "alac" | "wav" | "aiff" | "aif")
);
if !playable_via_cue {
log::warn!(
"[ephemeral] CUE references unsupported audio format ({:?}) — skipping: {}",
ext_lower,
canonical.display()
);
skipped_files += 1;
continue;
}
let properties = match MetadataExtractor::extract_properties(&canonical) {
Ok(p) => p,
Err(e) => {
log::warn!(
"[ephemeral] failed to read audio properties for {}: {}",
canonical.display(),
e
);
skipped_files += 1;
continue;
}
};
let format = MetadataExtractor::detect_format(&canonical);
let mut cue_tracks =
cue_to_tracks(&cue, properties.duration_secs, format, &properties);
if cue_tracks.is_empty() {
log::warn!("[ephemeral] CUE produced no tracks: {}", cue_path.display());
skipped_files += 1;
continue;
}
// CUE = single album: resolve cover once, share across
// every CUE-derived track. Use a key derived from the
// CUE path so the cache survives even when the
// album_group_key field is empty (rare but possible
// for CUE files without explicit TITLE/PERFORMER).
let album_key = if !cue_tracks[0].album_group_key.is_empty() {
cue_tracks[0].album_group_key.clone()
} else {
format!("cue:{}", cue.file_path)
};
let artwork = if let Some(cached) = album_artwork_cache.get(&album_key) {
cached.clone()
} else {
let mut found =
MetadataExtractor::extract_artwork(&canonical, &artwork_cache);
if found.is_none() {
if let Some(folder_art) =
MetadataExtractor::find_folder_artwork(&canonical, cue.title.as_deref())
{
found = MetadataExtractor::cache_artwork_file(
Path::new(&folder_art),
&artwork_cache,
);
}
}
album_artwork_cache.insert(album_key, found.clone());
found
};
for mut track in cue_tracks.drain(..) {
track.id = inner.next_id;
inner.next_id += 1;
track.source = Some("ephemeral".to_string());
track.artwork_path = artwork.clone();
inner.tracks.insert(track.id, track.clone());
tracks_out.push(track);
}
cue_referenced_audio.insert(canonical);
}
Err(e) => {
log::warn!("[ephemeral] failed to parse CUE {}: {}", cue_path.display(), e);
skipped_files += 1;
}
}
}
for audio_file in &scan.audio_files {
// Skip audio files that were already exploded into tracks via
// a CUE sheet — listing them again as a single row would
// duplicate the album and confuse playback (the CUE-derived
// track ids are the canonical ones).
let canonical_audio =
std::fs::canonicalize(audio_file).unwrap_or_else(|_| audio_file.clone());
if cue_referenced_audio.contains(&canonical_audio) {
continue;
}
// The scanner accepts APE because the regular library tracks
// them for tag/metadata purposes, but Symphonia can't decode
// Monkey's Audio. In ephemeral mode there is no value in
// surfacing rows that explode on click — skip them so the
// pane only shows tracks the user can actually play.
let ext_lower = audio_file
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase());
if matches!(ext_lower.as_deref(), Some("ape")) {
log::info!(
"[ephemeral] skipping APE (no Symphonia decoder): {}",
audio_file.display()
);
skipped_files += 1;
continue;
}
match MetadataExtractor::extract(audio_file) {
Ok(mut track) => {
track.id = inner.next_id;
inner.next_id += 1;
track.source = Some("ephemeral".to_string());
let album_key = if !track.album_group_key.is_empty() {
track.album_group_key.clone()
} else {
format!(
"{}|||{}",
track.album,
track.album_artist.as_deref().unwrap_or(&track.artist)
)
};
let artwork = if let Some(cached) = album_artwork_cache.get(&album_key) {
cached.clone()
} else {
let mut found =
MetadataExtractor::extract_artwork(audio_file, &artwork_cache);
if found.is_none() {
let folder_key = audio_file
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| audio_file.to_path_buf());
let folder_art = folder_artwork_cache
.entry(folder_key)
.or_insert_with(|| {
MetadataExtractor::find_folder_artwork(
audio_file,
Some(track.album.as_str()),
)
})
.clone();
if let Some(folder_art) = folder_art {
found = MetadataExtractor::cache_artwork_file(
std::path::Path::new(&folder_art),
&artwork_cache,
);
}
}
album_artwork_cache.insert(album_key, found.clone());
found
};
track.artwork_path = artwork;
inner.tracks.insert(track.id, track.clone());
tracks_out.push(track);
}
Err(e) => {
log::warn!(
"[ephemeral] failed to extract metadata from {}: {}",
audio_file.display(),
e
);
skipped_files += 1;
}
}
}
let folder_path = path.display().to_string();
inner.current_folder_path = Some(folder_path.clone());
log::info!(
"[ephemeral] opened {} ({} tracks, {} skipped)",
folder_path,
tracks_out.len(),
skipped_files
);
Ok(EphemeralFolderResult {
folder_path,
tracks: tracks_out,
skipped_files,
})
}
pub fn clear(&self) {
if let Ok(mut inner) = self.inner.lock() {
inner.reset();
}
}
/// Resolve a synthetic high id to the cached `LocalTrack`. Returns
/// `None` if the id is unknown (stale queue entry from a previous
/// session, race against `clear`, etc.).
pub fn get_track(&self, id: i64) -> Option<LocalTrack> {
let inner = self.inner.lock().ok()?;
inner.tracks.get(&id).cloned()
}
/// Snapshot of every track in the current session, in stable id order
/// (insertion order = scan order). Used by the Slint UI to build a
/// queue from the whole folder or a single album group.
pub fn tracks_snapshot(&self) -> Vec<LocalTrack> {
let Ok(inner) = self.inner.lock() else {
return Vec::new();
};
let mut tracks: Vec<LocalTrack> = inner.tracks.values().cloned().collect();
tracks.sort_by_key(|t| t.id);
tracks
}
/// The path of the currently-open ephemeral folder, if any.
pub fn current_folder_path(&self) -> Option<String> {
self.inner.lock().ok()?.current_folder_path.clone()
}
}
impl Default for EphemeralLibraryState {
fn default() -> Self {
Self::new()
}
}
+1
View File
@@ -31,6 +31,7 @@
pub mod album_grouping; pub mod album_grouping;
mod cue_parser; mod cue_parser;
mod database; mod database;
pub mod ephemeral;
mod errors; mod errors;
mod metadata; mod metadata;
mod models; mod models;
+6 -394
View File
@@ -1,398 +1,10 @@
//! In-memory ephemeral library for ad-hoc folder playback. //! In-memory ephemeral library for ad-hoc folder playback.
//! //!
//! The user can point QBZ at a folder that lives outside their library //! The implementation now lives in the frontend-agnostic `qbz-library` crate
//! (a downloaded album they haven't decided to keep, an external drive, //! (`qbz_library::ephemeral`, ADR-006) so both the Tauri and Slint frontends
//! etc.), browse it, and play tracks from it without anything landing //! consume identical logic. This module is a thin re-export shim that keeps the
//! in `local_tracks`. The ephemeral session lives only in memory: a //! existing `crate::ephemeral_library::*` call sites compiling unchanged.
//! `HashMap<i64, LocalTrack>` keyed by *synthetic ids in the high
//! range* (>= `EPHEMERAL_ID_FLOOR` = 2^48). Synthetic ids in this range
//! are how the rest of the playback pipeline distinguishes ephemeral
//! tracks from DB-resolvable ones — local_tracks autoincrement IDs are
//! orders of magnitude smaller, so any track_id arriving at
//! `v2_library_play_track` at or above the floor is unambiguously
//! ephemeral and gets routed here instead of the DB.
//!
//! The high-positive design (instead of the obvious "use negatives")
//! exists because the queue/playback-context commands serialize ids as
//! `u64` end-to-end (V2QueueTrack, v2_set_playback_context) and reject
//! negative numbers at the serde boundary. Positive ids above the DB
//! range and below 2^53 (JS Number safe limit) are valid u64 *and*
//! survive the JSON round-trip without precision loss.
//!
//! Only one folder is held at a time; opening a new folder replaces the
//! previous session. The state vanishes on app exit by virtue of being
//! in-memory — nothing persists, no migration, no cleanup logic needed.
use std::collections::{HashMap, HashSet}; pub use qbz_library::ephemeral::{
use std::path::{Path, PathBuf}; EphemeralError, EphemeralFolderResult, EphemeralLibraryState, EPHEMERAL_ID_FLOOR,
use std::sync::Mutex;
use qbz_library::{
cue_to_tracks, CueParser, LibraryError, LibraryScanner, LocalTrack, MetadataExtractor,
}; };
use serde::Serialize;
/// Floor for synthetic ephemeral track ids. Any id at or above this
/// value is an ephemeral track; below it is a DB row id. Set high
/// enough to be impossible to collide with autoincrement DB ids in any
/// realistic library size, low enough to fit in JS Number's safe
/// integer range (2^53 - 1) so the JSON round-trip stays lossless.
pub const EPHEMERAL_ID_FLOOR: i64 = 1 << 48;
#[derive(Debug, Serialize, Clone)]
pub struct EphemeralFolderResult {
pub folder_path: String,
pub tracks: Vec<LocalTrack>,
pub skipped_files: usize,
}
#[derive(Debug)]
pub enum EphemeralError {
Lock,
Library(String),
Io(String),
}
impl std::fmt::Display for EphemeralError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Lock => write!(f, "ephemeral library state lock poisoned"),
Self::Library(msg) => write!(f, "{}", msg),
Self::Io(msg) => write!(f, "{}", msg),
}
}
}
impl From<LibraryError> for EphemeralError {
fn from(e: LibraryError) -> Self {
EphemeralError::Library(e.to_string())
}
}
struct EphemeralLibraryInner {
tracks: HashMap<i64, LocalTrack>,
next_id: i64,
current_folder_path: Option<String>,
}
impl EphemeralLibraryInner {
fn new() -> Self {
Self {
tracks: HashMap::new(),
next_id: EPHEMERAL_ID_FLOOR,
current_folder_path: None,
}
}
fn reset(&mut self) {
self.tracks.clear();
self.next_id = EPHEMERAL_ID_FLOOR;
self.current_folder_path = None;
}
}
pub struct EphemeralLibraryState {
inner: Mutex<EphemeralLibraryInner>,
}
impl EphemeralLibraryState {
pub fn new() -> Self {
Self {
inner: Mutex::new(EphemeralLibraryInner::new()),
}
}
/// Scan a folder, extract metadata for every supported audio file
/// found, assign synthetic negative ids and stash the result. The
/// previous ephemeral session, if any, is dropped.
pub fn open_folder(&self, path: &Path) -> Result<EphemeralFolderResult, EphemeralError> {
if !path.exists() {
return Err(EphemeralError::Io(format!(
"Folder does not exist: {}",
path.display()
)));
}
if !path.is_dir() {
return Err(EphemeralError::Io(format!(
"Not a directory: {}",
path.display()
)));
}
let scanner = LibraryScanner::new();
let scan = scanner.scan_directory(path)?;
let mut tracks_out: Vec<LocalTrack> = Vec::with_capacity(scan.audio_files.len());
let mut skipped_files: usize = 0;
let mut inner = self.inner.lock().map_err(|_| EphemeralError::Lock)?;
inner.reset();
// Cache directory for artwork thumbnails. Same one the regular
// index uses, so ephemeral artwork piggy-backs on the existing
// thumbnail pipeline (and gets evicted by the same housekeeping).
let artwork_cache = crate::library::get_artwork_cache_dir();
// Two artwork caches keyed at different granularities. The bigger
// win is the album-level cache: embedded covers are usually
// identical across every track of an album, so doing extract_artwork
// (Probe::open + thumbnail encode) 155 times for a 155-track album
// is wasted I/O. The folder-level cache is a smaller secondary
// saver for find_folder_artwork (cover.jpg lookup) when albums
// share the same parent directory.
let mut album_artwork_cache: HashMap<String, Option<String>> = HashMap::new();
let mut folder_artwork_cache: HashMap<PathBuf, Option<String>> = HashMap::new();
// Audio files referenced by CUE sheets. We index those audio files
// through the CUE path (one logical "album" file gets exploded
// into N virtual tracks) and skip them in the regular audio loop
// below — otherwise the user would see both the CUE-derived
// tracks and a single-row entry for the underlying FLAC/WAV.
let mut cue_referenced_audio: HashSet<PathBuf> = HashSet::new();
for cue_path in &scan.cue_files {
match CueParser::parse(cue_path) {
Ok(mut cue) => {
let audio_path_raw = Path::new(&cue.audio_file).to_path_buf();
let canonical = std::fs::canonicalize(&audio_path_raw)
.unwrap_or_else(|_| audio_path_raw.clone());
if !canonical.exists() {
log::warn!(
"[ephemeral] CUE references missing audio: {} -> {}",
cue_path.display(),
audio_path_raw.display()
);
skipped_files += 1;
continue;
}
cue.audio_file = canonical.to_string_lossy().to_string();
// The decoder behind play_data is Symphonia, which
// covers FLAC / MP3 / M4A (AAC + ALAC) / ALAC /
// WAV / AIFF out of the box (`features = ["all"]`).
// APE (Monkey's Audio) and raw BIN (CD-DA dumps
// without headers) aren't in that list: playback
// either errors out or produces white noise as
// Symphonia mis-probes the stream. Skip CUE files
// that point at those — better an empty pane than
// a track row that explodes on click.
let ext_lower = canonical
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase());
let playable_via_cue = matches!(
ext_lower.as_deref(),
Some("flac" | "mp3" | "m4a" | "alac" | "wav" | "aiff" | "aif")
);
if !playable_via_cue {
log::warn!(
"[ephemeral] CUE references unsupported audio format ({:?}) — skipping: {}",
ext_lower,
canonical.display()
);
skipped_files += 1;
continue;
}
let properties = match MetadataExtractor::extract_properties(&canonical) {
Ok(p) => p,
Err(e) => {
log::warn!(
"[ephemeral] failed to read audio properties for {}: {}",
canonical.display(),
e
);
skipped_files += 1;
continue;
}
};
let format = MetadataExtractor::detect_format(&canonical);
let mut cue_tracks =
cue_to_tracks(&cue, properties.duration_secs, format, &properties);
if cue_tracks.is_empty() {
log::warn!(
"[ephemeral] CUE produced no tracks: {}",
cue_path.display()
);
skipped_files += 1;
continue;
}
// CUE = single album: resolve cover once, share across
// every CUE-derived track. Use a key derived from the
// CUE path so the cache survives even when the
// album_group_key field is empty (rare but possible
// for CUE files without explicit TITLE/PERFORMER).
let album_key = if !cue_tracks[0].album_group_key.is_empty() {
cue_tracks[0].album_group_key.clone()
} else {
format!("cue:{}", cue.file_path)
};
let artwork = if let Some(cached) = album_artwork_cache.get(&album_key) {
cached.clone()
} else {
let mut found =
MetadataExtractor::extract_artwork(&canonical, &artwork_cache);
if found.is_none() {
if let Some(folder_art) = MetadataExtractor::find_folder_artwork(
&canonical,
cue.title.as_deref(),
) {
found = MetadataExtractor::cache_artwork_file(
Path::new(&folder_art),
&artwork_cache,
);
}
}
album_artwork_cache.insert(album_key, found.clone());
found
};
for mut track in cue_tracks.drain(..) {
track.id = inner.next_id;
inner.next_id += 1;
track.source = Some("ephemeral".to_string());
track.artwork_path = artwork.clone();
inner.tracks.insert(track.id, track.clone());
tracks_out.push(track);
}
cue_referenced_audio.insert(canonical);
}
Err(e) => {
log::warn!(
"[ephemeral] failed to parse CUE {}: {}",
cue_path.display(),
e
);
skipped_files += 1;
}
}
}
for audio_file in &scan.audio_files {
// Skip audio files that were already exploded into tracks via
// a CUE sheet — listing them again as a single row would
// duplicate the album and confuse playback (the CUE-derived
// track ids are the canonical ones).
let canonical_audio = std::fs::canonicalize(audio_file)
.unwrap_or_else(|_| audio_file.clone());
if cue_referenced_audio.contains(&canonical_audio) {
continue;
}
// The scanner accepts APE because the regular library tracks
// them for tag/metadata purposes, but Symphonia can't decode
// Monkey's Audio. In ephemeral mode there is no value in
// surfacing rows that explode on click — skip them so the
// pane only shows tracks the user can actually play.
let ext_lower = audio_file
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase());
if matches!(ext_lower.as_deref(), Some("ape")) {
log::info!(
"[ephemeral] skipping APE (no Symphonia decoder): {}",
audio_file.display()
);
skipped_files += 1;
continue;
}
match MetadataExtractor::extract(audio_file) {
Ok(mut track) => {
track.id = inner.next_id;
inner.next_id += 1;
track.source = Some("ephemeral".to_string());
let album_key = if !track.album_group_key.is_empty() {
track.album_group_key.clone()
} else {
format!(
"{}|||{}",
track.album,
track.album_artist.as_deref().unwrap_or(&track.artist)
)
};
let artwork = if let Some(cached) = album_artwork_cache.get(&album_key) {
cached.clone()
} else {
let mut found =
MetadataExtractor::extract_artwork(audio_file, &artwork_cache);
if found.is_none() {
let folder_key = audio_file
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| audio_file.to_path_buf());
let folder_art = folder_artwork_cache
.entry(folder_key)
.or_insert_with(|| {
MetadataExtractor::find_folder_artwork(
audio_file,
Some(track.album.as_str()),
)
})
.clone();
if let Some(folder_art) = folder_art {
found = MetadataExtractor::cache_artwork_file(
std::path::Path::new(&folder_art),
&artwork_cache,
);
}
}
album_artwork_cache.insert(album_key, found.clone());
found
};
track.artwork_path = artwork;
inner.tracks.insert(track.id, track.clone());
tracks_out.push(track);
}
Err(e) => {
log::warn!(
"[ephemeral] failed to extract metadata from {}: {}",
audio_file.display(),
e
);
skipped_files += 1;
}
}
}
let folder_path = path.display().to_string();
inner.current_folder_path = Some(folder_path.clone());
log::info!(
"[ephemeral] opened {} ({} tracks, {} skipped)",
folder_path,
tracks_out.len(),
skipped_files
);
Ok(EphemeralFolderResult {
folder_path,
tracks: tracks_out,
skipped_files,
})
}
pub fn clear(&self) {
if let Ok(mut inner) = self.inner.lock() {
inner.reset();
}
}
/// Resolve a synthetic negative id to the cached `LocalTrack`. Returns
/// `None` if the id is unknown (stale queue entry from a previous
/// session, race against `clear`, etc.).
pub fn get_track(&self, id: i64) -> Option<LocalTrack> {
let inner = self.inner.lock().ok()?;
inner.tracks.get(&id).cloned()
}
}
impl Default for EphemeralLibraryState {
fn default() -> Self {
Self::new()
}
}