Skip to main content

mtag_cli/
planner.rs

1use std::{
2    collections::{HashMap, HashSet},
3    path::{Path, PathBuf},
4};
5
6use crate::{
7    error::{MtagError, MtagResult},
8    metadata::TrackMetadata,
9};
10
11/// Default destination template used by the CLI.
12///
13/// This renders files as `{album_artist}/{album}/{file_name}`.
14pub const DEFAULT_TEMPLATE: &str = "{album_artist}/{album}/{file_name}";
15
16/// Options that control how source metadata becomes destination paths.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct OrganizationOptions {
19    /// Slash-separated destination template.
20    ///
21    /// Each rendered segment is sanitized before it is pushed into the target path.
22    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    /// Returns a template that writes every planned file directly into the target root.
35    pub fn flat() -> Self {
36        Self {
37            template: "{file_name}".to_string(),
38        }
39    }
40}
41
42/// Filesystem operations produced by organization planning.
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct CopyPlan {
45    /// Ordered file operations to apply.
46    pub tasks: Vec<CopyTask>,
47}
48
49/// One file operation from a source path to a destination path.
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct CopyTask {
52    /// Existing source file.
53    pub from: PathBuf,
54    /// Destination path after metadata rendering and path sanitization.
55    pub to: PathBuf,
56}
57
58#[derive(Clone, Debug)]
59struct AlbumGroup {
60    artists: HashSet<String>,
61}
62
63/// Builds copy tasks from metadata without touching destination files.
64///
65/// The planner also looks for same-stem `.lrc` files next to each source audio file
66/// and emits companion tasks for them when present.
67///
68/// Album grouping rules:
69///
70/// - Use `album_artist` when the tag provides one.
71/// - Use `Various Artists` when tracks in the same source folder share an album name
72///   but have different artists.
73/// - Keep same-named albums from different source folders separate.
74///
75/// # Errors
76///
77/// Returns [`MtagError::MissingFileName`] when a source path has no file name,
78/// [`MtagError::InvalidTemplateVariable`] for unknown template variables, and
79/// [`MtagError::UnclosedTemplateVariable`] for malformed template segments.
80pub 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
128/// Sanitizes a metadata value before using it as one path component.
129///
130/// ```
131/// assert_eq!(mtag_cli::planner::sanitize_path_component("AC/DC: Live?"), "AC_DC_ Live_");
132/// assert_eq!(mtag_cli::planner::sanitize_path_component(".."), "_");
133/// ```
134pub 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}