fix: track number display for compilations and box sets (#147)
- Add filename-based track number inference as fallback when tags lack track_number - Detect degenerate track numbering (all tracks same number) and use index fallback - Flatten single-track-per-disc box sets into sequential numbering - Replace fragile SELECT * with explicit column list in track queries
This commit is contained in:
3 files changed
+198
-51
No files matched your search
@@ -1047,7 +1047,7 @@ impl LibraryDatabase {
|
||||
pub fn get_track(&self, id: i64) -> Result<Option<LocalTrack>, LibraryError> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT * FROM local_tracks WHERE id = ?")
|
||||
.prepare(&format!("SELECT {} FROM local_tracks WHERE id = ?", Self::TRACK_COLUMNS))
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
stmt.query_row(params![id], |row| Self::row_to_track(row))
|
||||
@@ -1059,7 +1059,7 @@ impl LibraryDatabase {
|
||||
pub fn get_track_by_path(&self, path: &str) -> Result<Option<LocalTrack>, LibraryError> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT * FROM local_tracks WHERE file_path = ? AND cue_file_path IS NULL")
|
||||
.prepare(&format!("SELECT {} FROM local_tracks WHERE file_path = ? AND cue_file_path IS NULL", Self::TRACK_COLUMNS))
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
stmt.query_row(params![path], |row| Self::row_to_track(row))
|
||||
@@ -1318,15 +1318,15 @@ impl LibraryDatabase {
|
||||
|
||||
/// Get tracks for an album group
|
||||
pub fn get_album_tracks(&self, group_key: &str) -> Result<Vec<LocalTrack>, LibraryError> {
|
||||
let sql = format!(
|
||||
"SELECT {} FROM local_tracks \
|
||||
WHERE COALESCE(album_group_key, album || '|' || COALESCE(album_artist, artist)) = ? \
|
||||
ORDER BY disc_number, track_number, title",
|
||||
Self::TRACK_COLUMNS
|
||||
);
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
r#"
|
||||
SELECT * FROM local_tracks
|
||||
WHERE COALESCE(album_group_key, album || '|' || COALESCE(album_artist, artist)) = ?
|
||||
ORDER BY disc_number, track_number, title
|
||||
"#,
|
||||
)
|
||||
.prepare(&sql)
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
let rows = stmt
|
||||
@@ -1689,13 +1689,10 @@ impl LibraryDatabase {
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT * FROM local_tracks
|
||||
WHERE (title LIKE ?1 OR artist LIKE ?1 OR album LIKE ?1)
|
||||
{} {}
|
||||
{}
|
||||
"#,
|
||||
source_filter, network_filter, limit_clause
|
||||
"SELECT {} FROM local_tracks \
|
||||
WHERE (title LIKE ?1 OR artist LIKE ?1 OR album LIKE ?1) \
|
||||
{} {} {}",
|
||||
Self::TRACK_COLUMNS, source_filter, network_filter, limit_clause
|
||||
);
|
||||
|
||||
let mut stmt = self
|
||||
@@ -1756,35 +1753,44 @@ impl LibraryDatabase {
|
||||
// === Helpers ===
|
||||
|
||||
/// Convert a database row to LocalTrack
|
||||
/// Column list for SELECT queries (avoids fragile SELECT * with positional indices)
|
||||
const TRACK_COLUMNS: &'static str =
|
||||
"id, file_path, title, artist, album, album_artist, \
|
||||
track_number, disc_number, year, genre, duration_secs, format, \
|
||||
bit_depth, sample_rate, channels, file_size_bytes, \
|
||||
cue_file_path, cue_start_secs, cue_end_secs, artwork_path, \
|
||||
last_modified, indexed_at, album_group_key, album_group_title, \
|
||||
source, qobuz_track_id, catalog_number";
|
||||
|
||||
fn row_to_track(row: &rusqlite::Row) -> rusqlite::Result<LocalTrack> {
|
||||
Ok(LocalTrack {
|
||||
id: row.get(0)?,
|
||||
file_path: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
artist: row.get(3)?,
|
||||
album: row.get(4)?,
|
||||
album_artist: row.get(5)?,
|
||||
album_group_key: row.get::<_, Option<String>>(22)?.unwrap_or_default(),
|
||||
album_group_title: row.get::<_, Option<String>>(23)?.unwrap_or_default(),
|
||||
track_number: row.get(6)?,
|
||||
disc_number: row.get(7)?,
|
||||
year: row.get(8)?,
|
||||
genre: row.get(9)?,
|
||||
catalog_number: row.get(26).ok().flatten(),
|
||||
duration_secs: row.get(10)?,
|
||||
format: Self::parse_format(&row.get::<_, String>(11)?),
|
||||
bit_depth: row.get(12)?,
|
||||
sample_rate: row.get::<_, f64>(13)?,
|
||||
channels: row.get(14)?,
|
||||
file_size_bytes: row.get(15)?,
|
||||
cue_file_path: row.get(16)?,
|
||||
cue_start_secs: row.get(17)?,
|
||||
cue_end_secs: row.get(18)?,
|
||||
artwork_path: row.get(19)?,
|
||||
last_modified: row.get(20)?,
|
||||
indexed_at: row.get(21)?,
|
||||
source: row.get(24).ok().flatten(),
|
||||
qobuz_track_id: row.get(25).ok().flatten(),
|
||||
id: row.get(0)?, // id
|
||||
file_path: row.get(1)?, // file_path
|
||||
title: row.get(2)?, // title
|
||||
artist: row.get(3)?, // artist
|
||||
album: row.get(4)?, // album
|
||||
album_artist: row.get(5)?, // album_artist
|
||||
track_number: row.get(6)?, // track_number
|
||||
disc_number: row.get(7)?, // disc_number
|
||||
year: row.get(8)?, // year
|
||||
genre: row.get(9)?, // genre
|
||||
duration_secs: row.get(10)?, // duration_secs
|
||||
format: Self::parse_format(&row.get::<_, String>(11)?), // format
|
||||
bit_depth: row.get(12)?, // bit_depth
|
||||
sample_rate: row.get::<_, f64>(13)?, // sample_rate
|
||||
channels: row.get(14)?, // channels
|
||||
file_size_bytes: row.get(15)?, // file_size_bytes
|
||||
cue_file_path: row.get(16)?, // cue_file_path
|
||||
cue_start_secs: row.get(17)?, // cue_start_secs
|
||||
cue_end_secs: row.get(18)?, // cue_end_secs
|
||||
artwork_path: row.get(19)?, // artwork_path
|
||||
last_modified: row.get(20)?, // last_modified
|
||||
indexed_at: row.get(21)?, // indexed_at
|
||||
album_group_key: row.get::<_, Option<String>>(22)?.unwrap_or_default(), // album_group_key
|
||||
album_group_title: row.get::<_, Option<String>>(23)?.unwrap_or_default(), // album_group_title
|
||||
source: row.get(24).ok().flatten(), // source
|
||||
qobuz_track_id: row.get(25).ok().flatten(), // qobuz_track_id
|
||||
catalog_number: row.get(26).ok().flatten(), // catalog_number
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +176,73 @@ impl MetadataExtractor {
|
||||
None
|
||||
}
|
||||
|
||||
/// Try to extract a track number from the filename.
|
||||
/// Handles common patterns like:
|
||||
/// - "01 - Title.flac"
|
||||
/// - "01. Title.flac"
|
||||
/// - "01_Title.flac"
|
||||
/// - "Track 01.flac"
|
||||
/// - "1-01 Title.flac" (disc-track)
|
||||
pub fn infer_track_number_from_filename(file_path: &Path) -> Option<u32> {
|
||||
let stem = file_path.file_stem()?.to_str()?;
|
||||
let trimmed = stem.trim();
|
||||
|
||||
// Pattern: starts with digits
|
||||
if let Some(cap) = trimmed.strip_prefix(|c: char| c.is_ascii_digit()) {
|
||||
// Collect leading digits
|
||||
let digit_end = 1 + cap.chars().take_while(|c| c.is_ascii_digit()).count();
|
||||
let num_str = &trimmed[..digit_end];
|
||||
let rest = &trimmed[digit_end..];
|
||||
|
||||
// Check for disc-track pattern FIRST: "D-TT" like "1-01 Title", "2-05 Song"
|
||||
// Only when leading number is 1-2 digits (disc number) followed by dash+digits
|
||||
if digit_end <= 2 && rest.starts_with('-') {
|
||||
let after_dash = &rest[1..];
|
||||
let track_digits: String = after_dash.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if !track_digits.is_empty() {
|
||||
if let Ok(n) = track_digits.parse::<u32>() {
|
||||
if n > 0 && n < 10000 {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regular pattern: digits followed by separator
|
||||
// "01 - Title", "01. Title", "01_Title", "01-Title"
|
||||
let rest_trimmed = rest.trim_start();
|
||||
let has_separator = rest_trimmed.starts_with('-')
|
||||
|| rest_trimmed.starts_with('.')
|
||||
|| rest_trimmed.starts_with('_')
|
||||
|| rest_trimmed.starts_with(' ')
|
||||
|| rest_trimmed.is_empty();
|
||||
|
||||
if has_separator {
|
||||
if let Ok(n) = num_str.parse::<u32>() {
|
||||
if n > 0 && n < 10000 {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern: "Track 01" or "Track01"
|
||||
let lower = trimmed.to_lowercase();
|
||||
if lower.starts_with("track") {
|
||||
let after = trimmed[5..].trim_start();
|
||||
let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if !digits.is_empty() {
|
||||
if let Ok(n) = digits.parse::<u32>() {
|
||||
if n > 0 && n < 10000 {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()?;
|
||||
@@ -351,7 +418,8 @@ impl MetadataExtractor {
|
||||
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),
|
||||
track_number: tag.track().map(|t| t as u32)
|
||||
.or_else(|| Self::infer_track_number_from_filename(file_path)),
|
||||
disc_number: tag
|
||||
.disk()
|
||||
.and_then(|d| if d > 0 { Some(d as u32) } else { None })
|
||||
@@ -395,7 +463,7 @@ impl MetadataExtractor {
|
||||
album_artist: None,
|
||||
album_group_key,
|
||||
album_group_title,
|
||||
track_number: None,
|
||||
track_number: Self::infer_track_number_from_filename(file_path),
|
||||
disc_number: inferred_disc,
|
||||
year: None,
|
||||
genre: None,
|
||||
@@ -719,4 +787,52 @@ mod tests {
|
||||
let root = MetadataExtractor::album_root_dir(path).unwrap();
|
||||
assert_eq!(root, Path::new("/music/EELS/Beautiful Freak"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_track_number_from_filename() {
|
||||
// Common patterns: "01 - Title"
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/01 - Novocaine.flac")),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/12 - Beautiful Freak.flac")),
|
||||
Some(12)
|
||||
);
|
||||
// "01. Title"
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/03. Song Name.flac")),
|
||||
Some(3)
|
||||
);
|
||||
// "01_Title"
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/05_Track.flac")),
|
||||
Some(5)
|
||||
);
|
||||
// Just number
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/07.flac")),
|
||||
Some(7)
|
||||
);
|
||||
// "Track 01"
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/Track 09.flac")),
|
||||
Some(9)
|
||||
);
|
||||
// "1-01 Title" (disc-track)
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/2-05 Song.flac")),
|
||||
Some(5)
|
||||
);
|
||||
// Not a track number (title starting with non-track digits)
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/Novocaine.flac")),
|
||||
None
|
||||
);
|
||||
// Zero is not a valid track number
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/00 - Intro.flac")),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user