1use std::{
2 collections::{HashMap, HashSet},
3 path::{Path, PathBuf},
4};
5
6use crate::{
7 error::{MtagError, MtagResult},
8 metadata::TrackMetadata,
9};
10
11pub const DEFAULT_TEMPLATE: &str = "{album_artist}/{album}/{file_name}";
15
16#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct OrganizationOptions {
19 pub template: String,
23}
24
25impl Default for OrganizationOptions {
26 fn default() -> Self {
27 Self {
28 template: DEFAULT_TEMPLATE.to_string(),
29 }
30 }
31}
32
33impl OrganizationOptions {
34 pub fn flat() -> Self {
36 Self {
37 template: "{file_name}".to_string(),
38 }
39 }
40}
41
42#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct CopyPlan {
45 pub tasks: Vec<CopyTask>,
47}
48
49#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct CopyTask {
52 pub from: PathBuf,
54 pub to: PathBuf,
56}
57
58#[derive(Clone, Debug)]
59struct AlbumGroup {
60 artists: HashSet<String>,
61}
62
63pub fn build_copy_plan(
81 tracks: &[TrackMetadata],
82 target_root: &Path,
83 options: &OrganizationOptions,
84) -> MtagResult<CopyPlan> {
85 let groups = build_album_groups(tracks);
86 let mut tasks = Vec::new();
87
88 for track in tracks {
89 let album_artist = album_artist_for_track(track, &groups);
90 let target_path = render_target_path(track, target_root, options, &album_artist)?;
91 tasks.push(CopyTask {
92 from: track.source_path.clone(),
93 to: target_path,
94 });
95
96 if let Some(lyric_path) = lyric_companion_path(&track.source_path) {
97 let lyric_file_name =
98 lyric_path
99 .file_name()
100 .ok_or_else(|| MtagError::MissingFileName {
101 path: lyric_path.clone(),
102 })?;
103 let lyric_track = TrackMetadata {
104 source_path: lyric_path.clone(),
105 artist: track.artist.clone(),
106 album: track.album.clone(),
107 album_artist: Some(album_artist),
108 disc: track.disc.clone(),
109 track: track.track.clone(),
110 title: track.title.clone(),
111 };
112 let target_path = render_target_path_with_file_name(
113 &lyric_track,
114 target_root,
115 options,
116 lyric_file_name,
117 )?;
118 tasks.push(CopyTask {
119 from: lyric_path,
120 to: target_path,
121 });
122 }
123 }
124
125 Ok(CopyPlan { tasks })
126}
127
128pub fn sanitize_path_component(component: &str) -> String {
135 let sanitized: String = component
136 .chars()
137 .map(|ch| {
138 if ch.is_control() || matches!(ch, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
139 {
140 '_'
141 } else {
142 ch
143 }
144 })
145 .collect();
146 let trimmed = sanitized.trim().trim_matches('.');
147
148 if trimmed.is_empty() || trimmed == "." || trimmed == ".." {
149 "_".to_string()
150 } else {
151 trimmed.to_string()
152 }
153}
154
155fn build_album_groups(tracks: &[TrackMetadata]) -> HashMap<(String, PathBuf), AlbumGroup> {
156 let mut groups: HashMap<(String, PathBuf), AlbumGroup> = HashMap::new();
157
158 for track in tracks {
159 if track
160 .album_artist
161 .as_deref()
162 .and_then(clean_string)
163 .is_some()
164 {
165 continue;
166 }
167
168 let album = value_or_other(track.album.as_deref());
169 if album == "Other" {
170 continue;
171 }
172
173 let parent = track
174 .source_path
175 .parent()
176 .map(Path::to_path_buf)
177 .unwrap_or_default();
178 let artist = primary_artist(value_or_other(track.artist.as_deref()).as_str());
179 groups
180 .entry((album, parent))
181 .or_insert_with(|| AlbumGroup {
182 artists: HashSet::new(),
183 })
184 .artists
185 .insert(artist);
186 }
187
188 groups
189}
190
191fn album_artist_for_track(
192 track: &TrackMetadata,
193 groups: &HashMap<(String, PathBuf), AlbumGroup>,
194) -> String {
195 if let Some(album_artist) = track.album_artist.as_deref().and_then(clean_string) {
196 return album_artist;
197 }
198
199 let album = value_or_other(track.album.as_deref());
200 if album == "Other" {
201 return primary_artist(value_or_other(track.artist.as_deref()).as_str());
202 }
203
204 let parent = track
205 .source_path
206 .parent()
207 .map(Path::to_path_buf)
208 .unwrap_or_default();
209 let group_key = (album, parent);
210
211 if groups
212 .get(&group_key)
213 .is_some_and(|group| group.artists.len() > 1)
214 {
215 "Various Artists".to_string()
216 } else {
217 primary_artist(value_or_other(track.artist.as_deref()).as_str())
218 }
219}
220
221fn render_target_path(
222 track: &TrackMetadata,
223 target_root: &Path,
224 options: &OrganizationOptions,
225 album_artist: &str,
226) -> MtagResult<PathBuf> {
227 let file_name = track
228 .source_path
229 .file_name()
230 .ok_or_else(|| MtagError::MissingFileName {
231 path: track.source_path.clone(),
232 })?;
233
234 let track = TrackMetadata {
235 album_artist: Some(album_artist.to_string()),
236 ..track.clone()
237 };
238
239 render_target_path_with_file_name(&track, target_root, options, file_name)
240}
241
242fn render_target_path_with_file_name(
243 track: &TrackMetadata,
244 target_root: &Path,
245 options: &OrganizationOptions,
246 file_name: &std::ffi::OsStr,
247) -> MtagResult<PathBuf> {
248 let file_name = file_name.to_string_lossy();
249 let mut target_path = target_root.to_path_buf();
250
251 for segment in options.template.split('/') {
252 if segment.is_empty() {
253 continue;
254 }
255 let rendered = render_template_segment(track, segment, &file_name)?;
256 target_path.push(sanitize_path_component(&rendered));
257 }
258
259 Ok(target_path)
260}
261
262fn render_template_segment(
263 track: &TrackMetadata,
264 segment: &str,
265 file_name: &str,
266) -> MtagResult<String> {
267 let mut output = String::new();
268 let mut rest = segment;
269
270 while let Some(start) = rest.find('{') {
271 output.push_str(&rest[..start]);
272 let after_open = &rest[start + 1..];
273 let Some(end) = after_open.find('}') else {
274 return Err(MtagError::UnclosedTemplateVariable {
275 template: segment.to_string(),
276 });
277 };
278 let variable = &after_open[..end];
279 output.push_str(template_value(track, variable, file_name)?);
280 rest = &after_open[end + 1..];
281 }
282
283 output.push_str(rest);
284 Ok(output)
285}
286
287fn template_value<'a>(
288 track: &'a TrackMetadata,
289 variable: &str,
290 file_name: &'a str,
291) -> MtagResult<&'a str> {
292 match variable {
293 "album_artist" => Ok(track
294 .album_artist
295 .as_deref()
296 .and_then(clean_str)
297 .unwrap_or("Other")),
298 "album" => Ok(track
299 .album
300 .as_deref()
301 .and_then(clean_str)
302 .unwrap_or("Other")),
303 "artist" => Ok(track
304 .artist
305 .as_deref()
306 .and_then(clean_str)
307 .unwrap_or("Other")),
308 "disc" => Ok(track.disc.as_deref().and_then(clean_str).unwrap_or("")),
309 "track" => Ok(track.track.as_deref().and_then(clean_str).unwrap_or("")),
310 "title" => Ok(track.title.as_deref().and_then(clean_str).unwrap_or("")),
311 "file_name" => Ok(file_name),
312 _ => Err(MtagError::InvalidTemplateVariable {
313 variable: variable.to_string(),
314 }),
315 }
316}
317
318fn lyric_companion_path(source_path: &Path) -> Option<PathBuf> {
319 let mut lyric_path = source_path.to_path_buf();
320 lyric_path.set_extension("lrc");
321 lyric_path.exists().then_some(lyric_path)
322}
323
324fn value_or_other(value: Option<&str>) -> String {
325 value
326 .and_then(clean_string)
327 .unwrap_or_else(|| "Other".to_string())
328}
329
330fn clean_string(value: &str) -> Option<String> {
331 clean_str(value).map(ToOwned::to_owned)
332}
333
334fn clean_str(value: &str) -> Option<&str> {
335 let trimmed = value.trim();
336 (!trimmed.is_empty()).then_some(trimmed)
337}
338
339fn primary_artist(artist: &str) -> String {
340 artist
341 .split('/')
342 .next()
343 .and_then(clean_string)
344 .unwrap_or_else(|| "Other".to_string())
345}