fix(library): harden cross-source catalog data

Preserve per-disc and collection artwork, multi-value genres and remote metadata across Local, Plex, Jellyfin and Subsonic.

Apply source, format and quality filters before pagination, reject invalid CUE rows, and keep Qt library surfaces aligned with the selected physical version.

cx
This commit is contained in:
vicrodh committed 2026-08-25 18:26:06 -06:00
1 parent 1bd99aed67
commit d244213a64
40 files changed
+3061 -638

No files matched your search

+105 -7
View File
@@ -12,6 +12,11 @@ pub struct CueSheet {
pub file_path: String,
/// Referenced audio file (resolved to absolute path)
pub audio_file: String,
/// Distinct audio files named by FILE directives. QBZ only expands a
/// sheet into virtual tracks when this contains exactly one entry; a
/// multi-file CUE describes files that are already split and is treated
/// as a sidecar instead of a second copy of the album.
audio_files: Vec<String>,
/// Album title
pub title: Option<String>,
/// Album performer/artist
@@ -20,6 +25,22 @@ pub struct CueSheet {
pub tracks: Vec<CueTrack>,
}
impl CueSheet {
/// Whether every track in this sheet addresses one shared audio image.
///
/// `cue_to_tracks` models virtual ranges inside a single decoder source.
/// A CUE with several distinct FILE directives is a metadata sidecar for
/// already-split files and must not enter that path.
pub fn is_single_file_image(&self) -> bool {
self.audio_files.len() == 1
}
/// Number of distinct audio files referenced by the sheet.
pub fn audio_file_count(&self) -> usize {
self.audio_files.len()
}
}
/// A track within a CUE sheet
#[derive(Debug, Clone)]
pub struct CueTrack {
@@ -83,6 +104,7 @@ impl CueParser {
let mut sheet = CueSheet {
file_path: cue_path.to_string_lossy().to_string(),
audio_file: String::new(),
audio_files: Vec::new(),
title: None,
performer: None,
tracks: Vec::new(),
@@ -103,11 +125,16 @@ impl CueParser {
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();
let resolved = if let Some(parent) = cue_path.parent() {
parent.join(&filename).to_string_lossy().into_owned()
} else {
sheet.audio_file = filename;
filename
};
if sheet.audio_file.is_empty() {
sheet.audio_file.clone_from(&resolved);
}
if !sheet.audio_files.contains(&resolved) {
sheet.audio_files.push(resolved);
}
}
}
@@ -180,7 +207,11 @@ impl CueParser {
));
}
log::info!("Parsed CUE: {} tracks", sheet.tracks.len());
log::info!(
"Parsed CUE: {} tracks, {} audio file(s)",
sheet.tracks.len(),
sheet.audio_file_count()
);
Ok(sheet)
}
@@ -212,6 +243,13 @@ pub fn cue_to_tracks(
format: AudioFormat,
properties: &AudioProperties,
) -> Vec<LocalTrack> {
// This converter expresses every row as a time range in one decoder
// source. Feeding it a multi-file CUE used to bind all rows to the last
// FILE directive and publish zero-duration duplicates of the split files.
if !cue.is_single_file_image() || audio_duration_secs == 0 {
return Vec::new();
}
let mut tracks = Vec::new();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -225,13 +263,27 @@ pub fn cue_to_tracks(
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() {
let requested_end = if i + 1 < cue.tracks.len() {
cue.tracks[i + 1].start_secs
} else {
audio_duration_secs as f64
};
let end_secs = requested_end.min(audio_duration_secs as f64);
let duration = (end_secs - cue_track.start_secs).max(0.0) as u64;
let range_secs = end_secs - cue_track.start_secs;
if !range_secs.is_finite() || range_secs <= 0.0 {
log::warn!(
"Ignoring unplayable CUE track {}: start={} end={} file={}",
cue_track.number,
cue_track.start_secs,
end_secs,
cue.audio_file
);
continue;
}
// A valid sub-second range is still playable; keep its visible
// duration non-zero while cue_start/end retain frame precision.
let duration = range_secs.ceil().max(1.0) as u64;
tracks.push(LocalTrack {
id: 0,
@@ -253,6 +305,7 @@ pub fn cue_to_tracks(
disc_number: inferred_disc,
year: None,
genre: None,
genres: Vec::new(),
catalog_number: None,
duration_secs: duration,
format: format.clone(),
@@ -264,6 +317,7 @@ pub fn cue_to_tracks(
cue_start_secs: Some(cue_track.start_secs),
cue_end_secs: Some(end_secs),
artwork_path: None,
collection_artwork_path: None,
last_modified: 0,
indexed_at: now,
source: None,
@@ -307,4 +361,48 @@ mod tests {
assert_eq!(CueParser::extract_track_number("TRACK 01 AUDIO"), Some(1));
assert_eq!(CueParser::extract_track_number("TRACK 12 AUDIO"), Some(12));
}
#[test]
fn multi_file_cue_is_a_split_file_sidecar_not_an_image() {
let cue = CueParser::parse_content(
"PERFORMER \"Opeth\"\n\
TITLE \"Sorceress\"\n\
FILE \"01. Persephone.flac\" WAVE\n\
TRACK 01 AUDIO\n\
TITLE \"Persephone\"\n\
INDEX 01 00:00:00\n\
FILE \"02. Sorceress.flac\" WAVE\n\
TRACK 02 AUDIO\n\
TITLE \"Sorceress\"\n\
INDEX 01 00:00:00\n",
Path::new("/music/Sorceress/disc.cue"),
)
.unwrap();
assert_eq!(cue.audio_file_count(), 2);
assert!(!cue.is_single_file_image());
assert!(
cue_to_tracks(&cue, 240, AudioFormat::Flac, &AudioProperties::default()).is_empty()
);
}
#[test]
fn invalid_or_out_of_bounds_ranges_are_not_published() {
let cue = CueParser::parse_content(
"FILE \"album.flac\" WAVE\n\
TRACK 01 AUDIO\n\
TITLE \"Playable\"\n\
INDEX 01 00:00:00\n\
TRACK 02 AUDIO\n\
TITLE \"Past EOF\"\n\
INDEX 01 10:00:00\n",
Path::new("/music/album.cue"),
)
.unwrap();
let tracks = cue_to_tracks(&cue, 300, AudioFormat::Flac, &AudioProperties::default());
assert_eq!(tracks.len(), 1);
assert_eq!(tracks[0].title, "Playable");
assert_eq!(tracks[0].duration_secs, 300);
}
}
File diff suppressed because it is too large. Load diff
+55 -6
View File
@@ -38,6 +38,10 @@ use crate::{
};
use serde::Serialize;
fn should_expand_cue(cue: &crate::CueSheet) -> bool {
cue.is_single_file_image()
}
fn apply_sidecar_override(
track: &mut LocalTrack,
cache: &mut HashMap<PathBuf, Option<AlbumTagSidecar>>,
@@ -179,6 +183,20 @@ impl EphemeralLibraryState {
for cue_path in &scan.cue_files {
match CueParser::parse(cue_path) {
Ok(mut cue) => {
// A multi-file CUE is a sidecar for audio that has
// already been split (usually one FILE per TRACK). The
// regular audio loop below is authoritative in that
// layout. Expanding it as though it were one image would
// duplicate every row and bind the virtual entries to an
// arbitrary backing file.
if !should_expand_cue(&cue) {
log::info!(
"[ephemeral] treating multi-file CUE as sidecar ({} files): {}",
cue.audio_file_count(),
cue_path.display()
);
continue;
}
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());
@@ -258,9 +276,10 @@ impl EphemeralLibraryState {
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())
{
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,
@@ -323,8 +342,8 @@ impl EphemeralLibraryState {
sc.spawn(move || {
part.iter()
.map(|f| {
let canonical = std::fs::canonicalize(f)
.unwrap_or_else(|_| f.clone());
let canonical =
std::fs::canonicalize(f).unwrap_or_else(|_| f.clone());
(canonical, MetadataExtractor::extract(f))
})
.collect::<Vec<_>>()
@@ -613,8 +632,9 @@ impl Default for EphemeralLibraryState {
}
#[cfg(test)]
mod editor_tests {
mod tests {
use super::*;
use std::fs;
fn track_at(path: &Path) -> LocalTrack {
LocalTrack {
@@ -627,6 +647,35 @@ mod editor_tests {
}
}
#[test]
fn ephemeral_loader_only_expands_single_image_cues() {
let temp = tempfile::tempdir().unwrap();
let single_path = temp.path().join("single.cue");
fs::write(
&single_path,
"FILE \"album.flac\" WAVE\n\
TRACK 01 AUDIO\n\
INDEX 01 00:00:00\n\
TRACK 02 AUDIO\n\
INDEX 01 03:00:00\n",
)
.unwrap();
let multi_path = temp.path().join("split.cue");
fs::write(
&multi_path,
"FILE \"01. First.flac\" WAVE\n\
TRACK 01 AUDIO\n\
INDEX 01 00:00:00\n\
FILE \"02. Second.flac\" WAVE\n\
TRACK 02 AUDIO\n\
INDEX 01 00:00:00\n",
)
.unwrap();
assert!(should_expand_cue(&CueParser::parse(&single_path).unwrap()));
assert!(!should_expand_cue(&CueParser::parse(&multi_path).unwrap()));
}
#[test]
fn sidecar_overrides_are_visible_to_ephemeral_scans() {
let temp = tempfile::tempdir().unwrap();
+40 -8
View File
@@ -1,7 +1,7 @@
//! Metadata extraction for audio files
use lofty::prelude::*;
use lofty::config::ParseOptions;
use lofty::prelude::*;
use lofty::probe::Probe;
use lofty::tag::ItemKey;
use std::fs;
@@ -43,13 +43,30 @@ impl MetadataExtractor {
/// First non-empty string for `key` across all of the file's tags
/// (primary first). When several tags disagree, the primary tag wins —
/// deterministic, and matches what other players show.
fn string_across_tags(
tagged_file: &lofty::file::TaggedFile,
key: &ItemKey,
) -> Option<String> {
fn string_across_tags(tagged_file: &lofty::file::TaggedFile, key: &ItemKey) -> Option<String> {
Self::string_from_tags(Self::tags_primary_first(tagged_file), key)
}
/// Every non-empty value for `key` across all tags, preserving primary-tag
/// order and deduplicating case-insensitively. This is intentionally
/// separate from the first-value fallback used by scalar metadata.
fn strings_across_tags(tagged_file: &lofty::file::TaggedFile, key: &ItemKey) -> Vec<String> {
let mut values = Vec::<String>::new();
for tag in Self::tags_primary_first(tagged_file) {
for value in tag.get_strings(key.clone()) {
let value = value.trim();
if !value.is_empty()
&& !values
.iter()
.any(|existing| existing.eq_ignore_ascii_case(value))
{
values.push(value.to_string());
}
}
}
values
}
/// Pure core of [`Self::string_across_tags`]: first non-empty, trimmed
/// value for `key` yielded by the tag iterator (already in priority
/// order). The empty check lives INSIDE the find_map so a blank value in
@@ -675,6 +692,7 @@ impl MetadataExtractor {
.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()));
let genres = Self::strings_across_tags(&tagged_file, &ItemKey::Genre);
LocalTrack {
id: 0,
@@ -694,7 +712,8 @@ impl MetadataExtractor {
.and_then(|d| if d > 0 { Some(d) } else { None })
.or(inferred_disc),
year: Self::year_across_tags(&tagged_file),
genre: Self::string_across_tags(&tagged_file, &ItemKey::Genre),
genre: genres.first().cloned(),
genres,
catalog_number: Self::string_across_tags(&tagged_file, &ItemKey::CatalogNumber),
duration_secs,
format,
@@ -706,6 +725,7 @@ impl MetadataExtractor {
cue_start_secs: None,
cue_end_secs: None,
artwork_path: None,
collection_artwork_path: None,
last_modified,
indexed_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -736,6 +756,7 @@ impl MetadataExtractor {
disc_number: inferred_disc,
year: None,
genre: None,
genres: Vec::new(),
catalog_number: None,
duration_secs,
format,
@@ -747,6 +768,7 @@ impl MetadataExtractor {
cue_start_secs: None,
cue_end_secs: None,
artwork_path: None,
collection_artwork_path: None,
last_modified,
indexed_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -795,6 +817,7 @@ impl MetadataExtractor {
.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()));
let genres = tags.genre.iter().cloned().collect();
Ok(LocalTrack {
id: 0,
@@ -813,6 +836,7 @@ impl MetadataExtractor {
disc_number: tags.disc_number.filter(|d| *d > 0).or(inferred_disc),
year: tags.year.and_then(|y| u32::try_from(y).ok()),
genre: tags.genre.clone(),
genres,
catalog_number: None,
duration_secs: info.duration_secs(),
format: AudioFormat::Dsd,
@@ -826,6 +850,7 @@ impl MetadataExtractor {
cue_start_secs: None,
cue_end_secs: None,
artwork_path: None,
collection_artwork_path: None,
last_modified,
indexed_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -992,6 +1017,7 @@ impl MetadataExtractor {
/// path and changes no stored value.
pub fn folder_artwork_in_dir(dir: &Path) -> Option<String> {
let mut best: Option<(u32, PathBuf)> = None;
let mut candidates = Vec::new();
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let path = entry.path();
if !path.is_file() {
@@ -1005,14 +1031,14 @@ impl MetadataExtractor {
if !Self::is_supported_artwork_ext(&ext) {
continue;
}
candidates.push(path.clone());
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase())
.unwrap_or_default();
// Same vocabulary the scanner scores with, simplified: an exact
// well-known name wins, a name that merely contains one is a
// fallback, anything else is ignored.
// well-known name wins and a name containing one is a fallback.
const EXACT: &[&str] = &["cover", "folder", "front", "album", "artwork", "art"];
let score = if EXACT.contains(&stem.as_str()) {
2
@@ -1026,6 +1052,12 @@ impl MetadataExtractor {
}
}
best.map(|(_, p)| p.to_string_lossy().to_string())
.or_else(|| {
// A single image in a disc directory is unambiguous even when
// it is named after the movement rather than `cover.jpg`.
(candidates.len() == 1)
.then(|| candidates[0].to_string_lossy().to_string())
})
}
pub fn find_folder_artwork(
+19
View File
@@ -53,6 +53,10 @@ pub struct LocalTrack {
pub disc_number: Option<u32>,
pub year: Option<u32>,
pub genre: Option<String>,
/// Complete ordered genre set. `genre` remains the compatibility primary
/// value for old databases and single-value tag writers.
#[serde(default)]
pub genres: Vec<String>,
pub catalog_number: Option<String>,
// Audio properties
@@ -70,6 +74,10 @@ pub struct LocalTrack {
// Artwork
pub artwork_path: Option<String>,
/// Album/collection artwork kept apart from a track or disc cover.
/// Consumers resolve artwork from the most specific layer outward.
#[serde(default)]
pub collection_artwork_path: Option<String>,
// Indexing
pub last_modified: i64,
@@ -104,6 +112,7 @@ impl Default for LocalTrack {
disc_number: None,
year: None,
genre: None,
genres: Vec::new(),
catalog_number: None,
duration_secs: 0,
format: AudioFormat::Unknown,
@@ -115,6 +124,7 @@ impl Default for LocalTrack {
cue_start_secs: None,
cue_end_secs: None,
artwork_path: None,
collection_artwork_path: None,
last_modified: 0,
indexed_at: 0,
source: None,
@@ -154,7 +164,16 @@ pub struct LocalAlbum {
pub all_artists: String,
pub year: Option<u32>,
pub catalog_number: Option<String>,
/// Union of every track/version genre represented by this album.
#[serde(default)]
pub genres: Vec<String>,
pub artwork_path: Option<String>,
/// Authoritative source that owns `artwork_path` when an album row is a
/// frontend-side logical group. It may differ from `source`: the
/// best-audio copy can be coverless while another physical copy supplies
/// the group's representative artwork.
#[serde(default)]
pub artwork_source: Option<String>,
pub track_count: u32,
pub total_duration_secs: u64,
pub format: AudioFormat,
+173 -12
View File
@@ -143,6 +143,10 @@ enum PreparedAction {
Reuse,
Extract(ExtractTask),
Preserve(String),
/// A valid CUE with several FILE directives describes already-split
/// audio. Keep the CUE scan record but publish no virtual tracks and
/// retire rows produced by older single-file-only parsing.
IgnoreMultiFileCue,
SkipCueAudio,
}
@@ -339,31 +343,44 @@ fn apply_sidecar_override(
fn decorate_tracks(tracks: &mut [LocalTrack], artwork_cache: &Path, caches: &mut PassCaches) {
for track in tracks {
apply_sidecar_override(track, &mut caches.sidecars);
let album_key = if track.album_group_key.trim().is_empty() {
Path::new(&track.file_path)
.parent()
.unwrap_or_else(|| Path::new(&track.file_path))
.to_string_lossy()
.into_owned()
let audio_path = PathBuf::from(&track.file_path);
let own_dir = audio_path.parent().unwrap_or(audio_path.as_path());
let collection_key = if track.album_group_key.trim().is_empty() {
own_dir.to_string_lossy().into_owned()
} else {
track.album_group_key.clone()
};
let audio_path = PathBuf::from(&track.file_path);
let album_hint = if track.album_group_title.trim().is_empty() {
track.album.as_str()
} else {
track.album_group_title.as_str()
};
let artwork = caches.artwork.get_or_insert_with(album_key, || {
let embedded = MetadataExtractor::extract_artwork(&audio_path, artwork_cache);
embedded.or_else(|| {
// Artwork is track/disc metadata, not merely an album-card property.
// Resolve from the narrowest scope outwards so a box set cannot stamp
// Disc 01's cover onto every queue row and media-control surface:
// embedded tag -> this disc's folder -> collection root -> none.
let embedded_key = format!("embedded:{}", audio_path.to_string_lossy());
let embedded = caches.artwork.get_or_insert_with(embedded_key, || {
MetadataExtractor::extract_artwork(&audio_path, artwork_cache)
});
let disc_key = format!("disc-folder:{}", own_dir.to_string_lossy());
let disc_file = caches.artwork.get_or_insert_with(disc_key, || {
MetadataExtractor::folder_artwork_in_dir(own_dir).and_then(|path| {
MetadataExtractor::cache_artwork_file(Path::new(&path), artwork_cache)
})
});
let collection_art_key = format!("collection:{collection_key}");
let collection = caches
.artwork
.get_or_insert_with(collection_art_key, || {
MetadataExtractor::find_folder_artwork(&audio_path, Some(album_hint)).and_then(
|folder_art| {
MetadataExtractor::cache_artwork_file(Path::new(&folder_art), artwork_cache)
},
)
})
});
});
let artwork = embedded.or(disc_file).or(collection);
track.artwork_path = artwork;
}
}
@@ -735,10 +752,15 @@ fn commit_batch(
file_path: file.prepared.canonical_path.clone(),
error: error.clone(),
}),
PreparedAction::IgnoreMultiFileCue => {
remove_obsolete_cue_tracks(db, &file.prepared.canonical_path, &[])?;
metrics.extracted = metrics.extracted.saturating_add(1);
}
PreparedAction::SkipCueAudio => {}
}
let successful = matches!(file.prepared.action, PreparedAction::Reuse)
|| matches!(file.prepared.action, PreparedAction::IgnoreMultiFileCue)
|| matches!(file.tracks.as_ref(), Some(Ok(_)));
if let Some(value) = file.prepared.fingerprint.as_ref() {
// A present but unreadable file receives a negative scan row.
@@ -902,6 +924,39 @@ fn prepare_file(
let (action, dependency, current_cue_audio_path) = match parsed {
Ok(mut cue) => {
cue.file_path = canonical_path.clone();
if !cue.is_single_file_image() {
// Multi-file sheets are useful metadata sidecars but
// the referenced split audio files are the playable,
// independently tagged sources. Marking this as a
// successful observation also clears any virtual CUE
// rows left by the old parser while allowing the audio
// phase to index every real file normally.
let dependency =
format!("multi-file-sidecar-v1|files={}", cue.audio_file_count());
let action = if previous_cue_audio_path.is_none()
&& scan_file_matches(
db,
folder.id,
&canonical_path,
ScanFileKind::Cue,
&value,
&dependency,
)? {
PreparedAction::Reuse
} else {
PreparedAction::IgnoreMultiFileCue
};
return Ok(PreparedFile {
traversal_path: traversal_path.to_string_lossy().into_owned(),
canonical_path,
kind: ScanFileKind::Cue,
fingerprint: Some(value),
dependency,
current_cue_audio_path: None,
previous_cue_audio_path,
action,
});
}
let audio = normalize_path(Path::new(&cue.audio_file));
cue.audio_file = audio.to_string_lossy().into_owned();
let audio_path = cue.audio_file.clone();
@@ -1717,6 +1772,65 @@ mod tests {
fs::write(path, contents).unwrap();
}
fn write_multi_file_cue(path: &Path, tracks: &[(&str, &str)]) {
let mut contents = String::from("PERFORMER \"Fixture Artist\"\nTITLE \"Fixture Album\"\n");
for (number, (file, title)) in tracks.iter().enumerate() {
contents.push_str(&format!(
"FILE \"{}\" WAVE\n TRACK {:02} AUDIO\n TITLE \"{}\"\n INDEX 01 00:00:00\n",
file,
number + 1,
title
));
}
fs::write(path, contents).unwrap();
}
fn write_artwork(path: &Path, rgb: [u8; 3]) {
image::RgbImage::from_pixel(2, 2, image::Rgb(rgb))
.save(path)
.unwrap();
}
#[test]
fn artwork_resolution_prefers_each_disc_then_collection() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("Box Set");
let disc_one = root.join("Disc 01 - First");
let disc_two = root.join("Disc 02 - Second");
let disc_three = root.join("Disc 03 - No Own Art");
for directory in [&disc_one, &disc_two, &disc_three] {
fs::create_dir_all(directory).unwrap();
fs::write(directory.join("01.flac"), b"not-a-real-audio-file").unwrap();
}
write_artwork(&root.join("cover.png"), [10, 10, 10]);
write_artwork(&disc_one.join("cover.png"), [200, 10, 10]);
// A uniquely named image is still authoritative for its own disc.
write_artwork(&disc_two.join("TV Series Soundtrack 02.png"), [10, 200, 10]);
let track = |disc: u32, directory: &Path| LocalTrack {
file_path: directory.join("01.flac").to_string_lossy().into_owned(),
album: "Box Set".to_string(),
album_group_key: root.to_string_lossy().into_owned(),
album_group_title: "Box Set".to_string(),
disc_number: Some(disc),
..Default::default()
};
let mut tracks = vec![
track(1, &disc_one),
track(2, &disc_two),
track(3, &disc_three),
];
let mut caches = PassCaches::default();
decorate_tracks(&mut tracks, temp.path(), &mut caches);
let one = tracks[0].artwork_path.as_deref().unwrap();
let two = tracks[1].artwork_path.as_deref().unwrap();
let three = tracks[2].artwork_path.as_deref().unwrap();
assert_ne!(one, two, "two distinct disc covers collapsed");
assert_ne!(one, three, "disc 1 leaked into the collection fallback");
assert_ne!(two, three, "disc 2 leaked into the collection fallback");
}
#[test]
fn unchanged_files_skip_extraction_and_changed_file_keeps_its_id() {
let (temp, db, root_id, root) = fixture();
@@ -1834,6 +1948,53 @@ mod tests {
assert!(plain[0].2.is_none());
}
#[test]
fn multi_file_cue_retires_virtual_rows_and_indexes_only_split_audio() {
let (temp, db, root_id, root) = fixture();
let first_path = root.join("01. First.flac");
let second_path = root.join("02. Second.flac");
fs::write(&first_path, b"fixture first audio").unwrap();
fs::write(&second_path, b"fixture second audio").unwrap();
let cue_path = root.join("leftover.cue");
write_multi_file_cue(
&cue_path,
&[("01. First.flac", "First"), ("02. Second.flac", "Second")],
);
// Simulate rows produced by the legacy parser, which attached every
// virtual entry to one arbitrary FILE from the sheet.
for (index, title) in ["Legacy First", "Legacy Second"].iter().enumerate() {
let mut stale = LocalTrack::default();
stale.file_path = second_path.to_string_lossy().into_owned();
stale.title = (*title).to_string();
stale.artist = "Fixture Artist".to_string();
stale.album = "Fixture Album".to_string();
stale.album_group_key = root.to_string_lossy().into_owned();
stale.album_group_title = stale.album.clone();
stale.cue_file_path = Some(cue_path.to_string_lossy().into_owned());
stale.cue_start_secs = Some(index as f64);
db.insert_track(&stale).unwrap();
}
let cancel = Arc::new(AtomicBool::new(false));
let extraction = FakeExtraction::new(Arc::clone(&cancel));
run_fake(&db, root_id, &temp, &cancel, &extraction);
// Only the two real files require extraction; the valid multi-file
// CUE is observed as a sidecar and never reaches the CUE backend.
assert_eq!(extraction.calls.load(AtomicOrdering::SeqCst), 2);
let rows = cue_rows(&db);
assert_eq!(rows.len(), 2);
assert!(rows.iter().all(|(_, _, cue)| cue.is_none()));
let paths = track_rows(&db)
.into_iter()
.map(|(_, path)| path)
.collect::<HashSet<_>>();
assert_eq!(paths.len(), 2);
assert!(paths.contains(&first_path.to_string_lossy().into_owned()));
assert!(paths.contains(&second_path.to_string_lossy().into_owned()));
}
#[test]
fn root_prefix_treats_sql_wildcards_as_plain_path_characters() {
let temp = tempfile::tempdir().unwrap();