use std::{collections::HashMap, path::Path};
use crate::{
providers::{ProviderId, ProviderTrack},
types::{Track, TrackAlbum},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ImportMethod {
#[default]
Native,
FileList,
Csv,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportCsvField {
Name,
Artist,
Album,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CsvPreset {
#[default]
Default,
Exportify,
}
impl CsvPreset {
pub fn columns(self) -> (String, String, String) {
match self {
CsvPreset::Default => (
"name".to_string(),
"artist".to_string(),
"album".to_string(),
),
CsvPreset::Exportify => (
"Track Name".to_string(),
"Artist Name(s)".to_string(),
"Album Name".to_string(),
),
}
}
}
#[derive(Debug, Clone)]
pub struct ImportPlaylistDialog {
pub method: ImportMethod,
pub csv_preset: CsvPreset,
pub csv_name_col: String,
pub csv_artist_col: String,
pub csv_album_col: String,
pub patterns: Vec<String>,
pub playlist_name: String,
}
impl Default for ImportPlaylistDialog {
fn default() -> Self {
Self {
method: ImportMethod::default(),
csv_preset: CsvPreset::default(),
csv_name_col: "name".to_string(),
csv_artist_col: "artist".to_string(),
csv_album_col: "album".to_string(),
patterns: vec!["{artist} - {name} - {album}.{ext}".to_string()],
playlist_name: String::new(),
}
}
}
impl ImportPlaylistDialog {
pub fn conflict_pair(&self) -> Option<(String, String)> {
let items: Vec<(String, Vec<Part>)> = self
.patterns
.iter()
.map(|p| (p.trim().to_string(), parse_pattern(p)))
.filter(|(p, _)| !p.is_empty())
.collect();
for i in 0..items.len() {
for j in (i + 1)..items.len() {
if same_skeleton(&items[i].1, &items[j].1)
&& roles_conflict(&items[i].1, &items[j].1)
{
return Some((items[i].0.clone(), items[j].0.clone()));
}
}
}
None
}
pub fn apply_csv_preset(&mut self, preset: CsvPreset) {
self.csv_preset = preset;
let (name, artist, album) = preset.columns();
self.csv_name_col = name;
self.csv_artist_col = artist;
self.csv_album_col = album;
}
pub fn can_select(&self) -> bool {
match self.method {
ImportMethod::Native | ImportMethod::Csv => true,
ImportMethod::FileList => {
self.conflict_pair().is_none() && self.patterns.iter().any(|p| !p.trim().is_empty())
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Part {
Lit(String),
Var(Role),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Role {
Name,
Artist,
Album,
Other,
Wildcard,
}
fn role_for(token: &str) -> Role {
match token.trim().to_lowercase().as_str() {
"name" => Role::Name,
"artist" => Role::Artist,
"album" => Role::Album,
"ext" | "*" => Role::Wildcard,
_ => Role::Other,
}
}
fn parse_pattern(pattern: &str) -> Vec<Part> {
let mut parts = Vec::new();
let mut lit = String::new();
let mut chars = pattern.chars().peekable();
while let Some(c) = chars.next() {
if c == '{' {
let mut token = String::new();
for nc in chars.by_ref() {
if nc == '}' {
break;
}
token.push(nc);
}
if !lit.is_empty() {
parts.push(Part::Lit(std::mem::take(&mut lit)));
}
parts.push(Part::Var(role_for(&token)));
} else {
lit.push(c);
}
}
if !lit.is_empty() {
parts.push(Part::Lit(lit));
}
parts
}
fn same_skeleton(a: &[Part], b: &[Part]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).all(|(x, y)| match (x, y) {
(Part::Lit(s1), Part::Lit(s2)) => s1 == s2,
(Part::Var(_), Part::Var(_)) => true,
_ => false,
})
}
fn roles_conflict(a: &[Part], b: &[Part]) -> bool {
a.iter().zip(b).any(|(x, y)| match (x, y) {
(Part::Var(r1), Part::Var(r2))
if matches!(r1, Role::Name | Role::Artist | Role::Album | Role::Other)
&& matches!(r2, Role::Name | Role::Artist | Role::Album | Role::Other) =>
{
r1 != r2
}
_ => false,
})
}
fn match_parts(
parts: &[Part],
text: &str,
byte_offsets: &[usize],
pos: usize,
out: &mut HashMap<Role, String>,
) -> bool {
let Some((head, rest)) = parts.split_first() else {
return pos == byte_offsets.len() - 1;
};
match head {
Part::Lit(l) => {
text[byte_offsets[pos]..].starts_with(l.as_str())
&& match_parts(rest, text, byte_offsets, pos + l.chars().count(), out)
}
Part::Var(role) => {
let idx = rest.iter().position(|p| matches!(p, Part::Lit(_)));
if let Some(idx) = idx {
let Part::Lit(s) = &rest[idx] else {
unreachable!()
};
let lit = s;
let lit_chars = lit.chars().count();
if byte_offsets.len() - 1 < lit_chars {
return false;
}
for end in pos..=(byte_offsets.len() - 1 - lit_chars) {
if text[byte_offsets[end]..].starts_with(lit.as_str()) {
if matches!(role, Role::Name | Role::Artist | Role::Album | Role::Other) {
out.insert(
*role,
text[byte_offsets[pos]..byte_offsets[end]].to_string(),
);
}
if match_parts(&rest[idx + 1..], text, byte_offsets, end + lit_chars, out) {
return true;
}
out.remove(role);
}
}
false
} else {
let content = text[byte_offsets[pos]..].to_string();
if matches!(role, Role::Name | Role::Artist | Role::Album | Role::Other) {
out.insert(*role, content);
}
true
}
}
}
}
pub(crate) fn parse_filename(
patterns: &[String],
filename: &str,
) -> Option<(String, String, String)> {
let byte_offsets: Vec<usize> = std::iter::once(0)
.chain(filename.char_indices().map(|(i, _)| i))
.chain(std::iter::once(filename.len()))
.collect();
for raw in patterns {
let pattern = raw.trim();
if pattern.is_empty() {
continue;
}
let parts = parse_pattern(pattern);
if parts.is_empty() {
continue;
}
let mut out = HashMap::new();
if match_parts(&parts, filename, &byte_offsets, 0, &mut out) {
let name = out.get(&Role::Name).cloned().unwrap_or_default();
let artist = out.get(&Role::Artist).cloned().unwrap_or_default();
let album = out.get(&Role::Album).cloned().unwrap_or_default();
return Some((name, artist, album));
}
}
None
}
pub(crate) fn build_reference_track(title: String, artist: String, album: String) -> Track {
let mut providers = std::collections::HashMap::new();
let album_pt = if album.is_empty() {
None
} else {
Some(TrackAlbum {
name: album.clone(),
id: album,
})
};
providers.insert(
ProviderId::Local,
ProviderTrack {
id: title.clone(),
url: String::new(),
artist_id: None,
duration: 0,
thumbnail: String::new(),
album: album_pt,
play_count: 0,
},
);
Track {
title,
artist,
source: ProviderId::Local,
providers,
}
}
pub(crate) fn build_file_track(path: &Path, name: String, artist: String, album: String) -> Track {
let path_str = path.to_string_lossy().to_string();
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let title = if name.is_empty() { stem.clone() } else { name };
let duration = crate::util::try_probe_duration(&path_str).unwrap_or(0);
let mut providers = std::collections::HashMap::new();
let album_pt = if album.is_empty() {
None
} else {
Some(TrackAlbum {
name: album.clone(),
id: album,
})
};
providers.insert(
ProviderId::Local,
ProviderTrack {
id: stem,
url: path_str,
artist_id: None,
duration,
thumbnail: String::new(),
album: album_pt,
play_count: 0,
},
);
Track {
title,
artist,
source: ProviderId::Local,
providers,
}
}
pub(crate) fn gather_audio_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
let exts = ["mp3", "flac", "wav", "ogg", "m4a", "aac", "opus", "wma"];
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
gather_audio_files(&path, out);
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if exts.contains(&ext.to_lowercase().as_str()) {
out.push(path);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_pattern() {
let (name, artist, album) = parse_filename(
&["{name} - {artist} - {album}.{ext}".into()],
"Song - The Band - Debut.mp3",
)
.unwrap();
assert_eq!(name, "Song");
assert_eq!(artist, "The Band");
assert_eq!(album, "Debut");
}
#[test]
fn parse_first_matching_pattern_wins() {
let patterns = vec![
"{album}/{name}.{ext}".to_string(),
"{name} - {artist}.{ext}".to_string(),
];
let (name, artist, _) = parse_filename(&patterns, "Song - The Band.mp3").unwrap();
assert_eq!(name, "Song");
assert_eq!(artist, "The Band");
}
#[test]
fn unmatched_file_returns_none() {
assert!(parse_filename(&["{name} - {artist}.{ext}".into()], "justasong.mp3").is_none());
}
#[test]
fn multibyte_filename_parses() {
let (name, artist, album) = parse_filename(
&["{name} - {artist} - {album}.{ext}".into()],
"Schrödinger - Mötley Crüe - Über Album.mp3",
)
.unwrap();
assert_eq!(name, "Schrödinger");
assert_eq!(artist, "Mötley Crüe");
assert_eq!(album, "Über Album");
}
#[test]
fn conflicting_skeletons_detected() {
let dialog = ImportPlaylistDialog {
patterns: vec![
"{name} - {artist}.{ext}".to_string(),
"{artist} - {name}.{ext}".to_string(),
],
..Default::default()
};
assert!(dialog.conflict_pair().is_some());
}
#[test]
fn non_conflicting_skeletons_ok() {
let dialog = ImportPlaylistDialog {
patterns: vec![
"{name} - {artist}.{ext}".to_string(),
"{name} - {artist}.{mp3}".to_string(),
],
..Default::default()
};
assert!(dialog.conflict_pair().is_none());
}
#[test]
fn wildcard_ext_not_conflicting() {
let dialog = ImportPlaylistDialog {
patterns: vec![
"{name} - {artist}.{ext}".to_string(),
"{name} - {artist}.{*}".to_string(),
],
..Default::default()
};
assert!(dialog.conflict_pair().is_none());
}
}