204 lines
6.3 KiB
Rust
204 lines
6.3 KiB
Rust
use std::path::Path;
|
|
|
|
use crate::db::PathHints;
|
|
|
|
/// Parse metadata hints from the file path relative to the inbox directory.
|
|
///
|
|
/// Recognized patterns:
|
|
/// Artist/Album/01 - Title.ext
|
|
/// Artist/Album (Year)/01 - Title.ext
|
|
/// Artist/(Year) Album/01 - Title.ext
|
|
/// Artist/Album [Year]/01 - Title.ext
|
|
/// 01 - Title.ext (flat, no artist/album)
|
|
pub fn parse(relative_path: &Path) -> PathHints {
|
|
let components: Vec<&str> = relative_path
|
|
.components()
|
|
.filter_map(|c| c.as_os_str().to_str())
|
|
.collect();
|
|
|
|
let mut hints = PathHints::default();
|
|
|
|
let filename = components.last().copied().unwrap_or("");
|
|
let stem = Path::new(filename)
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("");
|
|
|
|
// Parse track number and title from filename
|
|
parse_filename(stem, &mut hints);
|
|
|
|
match components.len() {
|
|
// Artist/Album/file.ext
|
|
3.. => {
|
|
hints.artist = Some(components[0].to_owned());
|
|
|
|
let album_raw = components[1];
|
|
let (album, year) = parse_album_with_year(album_raw);
|
|
hints.album = Some(album);
|
|
if year.is_some() {
|
|
hints.year = year;
|
|
}
|
|
}
|
|
// Album/file.ext (or Artist/file.ext — ambiguous, treat as album)
|
|
2 => {
|
|
let dir = components[0];
|
|
let (name, year) = parse_album_with_year(dir);
|
|
hints.album = Some(name);
|
|
if year.is_some() {
|
|
hints.year = year;
|
|
}
|
|
}
|
|
// Just file.ext
|
|
_ => {}
|
|
}
|
|
|
|
hints
|
|
}
|
|
|
|
/// Try to extract track number and title from a filename stem.
|
|
///
|
|
/// Patterns: "01 - Title", "01. Title", "1 Title", "Title"
|
|
fn parse_filename(stem: &str, hints: &mut PathHints) {
|
|
let trimmed = stem.trim();
|
|
|
|
// Try "NN - Title" or "NN. Title"
|
|
if let Some(rest) = try_strip_track_prefix(trimmed) {
|
|
let (num_str, title) = rest;
|
|
if let Ok(num) = num_str.parse::<i32>() {
|
|
hints.track_number = Some(num);
|
|
if !title.is_empty() {
|
|
hints.title = Some(title.to_owned());
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// No track number found, use full stem as title
|
|
if !trimmed.is_empty() {
|
|
hints.title = Some(trimmed.to_owned());
|
|
}
|
|
}
|
|
|
|
/// Try to parse "NN - Rest" or "NN. Rest" from a string.
|
|
/// Returns (number_str, rest) if successful.
|
|
fn try_strip_track_prefix(s: &str) -> Option<(&str, &str)> {
|
|
// Find leading digits
|
|
let digit_end = s.find(|c: char| !c.is_ascii_digit())?;
|
|
if digit_end == 0 {
|
|
return None;
|
|
}
|
|
let num_str = &s[..digit_end];
|
|
let rest = s[digit_end..].trim_start();
|
|
|
|
// Expect separator: " - ", ". ", "- ", or just space if followed by letter
|
|
let title = if let Some(stripped) = rest.strip_prefix("- ") {
|
|
stripped.trim()
|
|
} else if let Some(stripped) = rest.strip_prefix(". ") {
|
|
stripped.trim()
|
|
} else if let Some(stripped) = rest.strip_prefix('.') {
|
|
stripped.trim()
|
|
} else if let Some(stripped) = rest.strip_prefix("- ") {
|
|
stripped.trim()
|
|
} else {
|
|
// Just "01 Title" — digits followed by space then text
|
|
rest
|
|
};
|
|
|
|
Some((num_str, title))
|
|
}
|
|
|
|
/// Public wrapper for cover image processing.
|
|
pub fn parse_album_year_public(dir: &str) -> (String, Option<i32>) {
|
|
parse_album_with_year(dir)
|
|
}
|
|
|
|
/// Extract album name and optional year from directory name.
|
|
///
|
|
/// Patterns: "Album (2001)", "(2001) Album", "Album [2001]", "Album"
|
|
fn parse_album_with_year(dir: &str) -> (String, Option<i32>) {
|
|
// Try "Album (YYYY)" or "Album [YYYY]"
|
|
for (open, close) in [('(', ')'), ('[', ']')] {
|
|
if let Some(start) = dir.rfind(open) {
|
|
if let Some(end) = dir[start..].find(close) {
|
|
let inside = &dir[start + 1..start + end];
|
|
if let Ok(year) = inside.trim().parse::<i32>() {
|
|
if (1900..=2100).contains(&year) {
|
|
let album = format!("{}{}", &dir[..start].trim(), &dir[start + end + 1..].trim());
|
|
let album = album.trim().to_owned();
|
|
return (album, Some(year));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try "(YYYY) Album"
|
|
if dir.starts_with('(') {
|
|
if let Some(end) = dir.find(')') {
|
|
let inside = &dir[1..end];
|
|
if let Ok(year) = inside.trim().parse::<i32>() {
|
|
if (1900..=2100).contains(&year) {
|
|
let album = dir[end + 1..].trim().to_owned();
|
|
return (album, Some(year));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
(dir.to_owned(), None)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::path::PathBuf;
|
|
|
|
#[test]
|
|
fn test_artist_album_track() {
|
|
let p = PathBuf::from("Pink Floyd/Wish You Were Here (1975)/03 - Have a Cigar.flac");
|
|
let h = parse(&p);
|
|
assert_eq!(h.artist.as_deref(), Some("Pink Floyd"));
|
|
assert_eq!(h.album.as_deref(), Some("Wish You Were Here"));
|
|
assert_eq!(h.year, Some(1975));
|
|
assert_eq!(h.track_number, Some(3));
|
|
assert_eq!(h.title.as_deref(), Some("Have a Cigar"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_year_prefix() {
|
|
let p = PathBuf::from("Artist/(2020) Album Name/01. Song.flac");
|
|
let h = parse(&p);
|
|
assert_eq!(h.artist.as_deref(), Some("Artist"));
|
|
assert_eq!(h.album.as_deref(), Some("Album Name"));
|
|
assert_eq!(h.year, Some(2020));
|
|
assert_eq!(h.track_number, Some(1));
|
|
assert_eq!(h.title.as_deref(), Some("Song"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_flat_file() {
|
|
let p = PathBuf::from("05 - Something.mp3");
|
|
let h = parse(&p);
|
|
assert_eq!(h.artist, None);
|
|
assert_eq!(h.album, None);
|
|
assert_eq!(h.track_number, Some(5));
|
|
assert_eq!(h.title.as_deref(), Some("Something"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_track_number() {
|
|
let p = PathBuf::from("Artist/Album/Song Name.flac");
|
|
let h = parse(&p);
|
|
assert_eq!(h.track_number, None);
|
|
assert_eq!(h.title.as_deref(), Some("Song Name"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_square_bracket_year() {
|
|
let p = PathBuf::from("Band/Album [1999]/track.flac");
|
|
let h = parse(&p);
|
|
assert_eq!(h.album.as_deref(), Some("Album"));
|
|
assert_eq!(h.year, Some(1999));
|
|
}
|
|
}
|