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:
vicrodh committed 2026-03-10 15:08:10 -06:00
1 parent 66595ca293
commit c7ab0ba129
3 files changed
+198 -51

No files matched your search

+49 -43
View File
@@ -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
})
}
+118 -2
View File
@@ -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
);
}
}