Skip to main content

koan_core/
organize.rs

1use std::collections::{HashMap, HashSet};
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use rusqlite::{Connection, params};
7
8use crate::db::connection::{Database, DbError};
9use crate::db::queries::{self, PersistedQueueItem, TrackRow};
10use crate::format::{self, FormatError, MetadataProvider};
11use crate::helpers::{sanitise_filename, truncate_bytes};
12
13/// Ancillary file patterns we move alongside audio files.
14const ANCILLARY_PATTERNS: &[&str] = &[
15    "cover.jpg",
16    "cover.png",
17    "cover.webp",
18    "folder.jpg",
19    "folder.png",
20    "front.jpg",
21    "front.png",
22];
23
24const ANCILLARY_EXTENSIONS: &[&str] = &["cue", "log", "m3u", "m3u8"];
25
26/// Byte ceiling for a destination file name, extension included. Filesystems we
27/// target cap a single name at 255 bytes.
28const MAX_FILE_NAME_BYTES: usize = 250;
29
30#[derive(Debug, thiserror::Error)]
31pub enum OrganizeError {
32    #[error("database error: {0}")]
33    Db(#[from] DbError),
34    #[error("sqlite error: {0}")]
35    Sqlite(#[from] rusqlite::Error),
36    #[error("format error: {0}")]
37    Format(#[from] FormatError),
38    #[error("io error: {0}")]
39    Io(#[from] std::io::Error),
40    #[error("no destination folder: add a library folder, or pass --base-dir")]
41    NoDestination,
42    #[error("no organize batches to undo")]
43    NothingToUndo,
44    #[error("destination already exists: {0}")]
45    DestinationExists(PathBuf),
46    #[error("copied {copied} of {expected} bytes from {path}")]
47    ShortCopy {
48        path: PathBuf,
49        expected: u64,
50        copied: u64,
51    },
52    #[error("not enough free space: {needed} bytes needed, {available} available")]
53    NotEnoughSpace { needed: u64, available: u64 },
54}
55
56/// What the pattern means for one file.
57///
58/// Conflicts are an outcome rather than an error off to one side: "this would
59/// overwrite something" is the single most important thing a preview can tell
60/// you, and it belongs on the row it concerns, next to the destination it would
61/// have landed on.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum PlanOutcome {
64    /// The file will be moved, or was.
65    Move,
66    /// Already exactly where the pattern puts it. Nothing to do.
67    Unchanged,
68    /// Something holds the destination — a file already there, or another file
69    /// in the same run that claimed it first. Nothing is ever overwritten, so
70    /// this file stays where it is.
71    Conflict(String),
72    /// The pattern produced nothing usable for this file, or the move failed.
73    Error(String),
74}
75
76impl PlanOutcome {
77    /// The reason a file isn't moving, for anything rendering it as text.
78    pub fn reason(&self) -> Option<&str> {
79        match self {
80            Self::Conflict(reason) | Self::Error(reason) => Some(reason),
81            _ => None,
82        }
83    }
84}
85
86/// One file's place in a plan: where it is, where the pattern puts it, and
87/// whether that can happen.
88#[derive(Debug, Clone)]
89pub struct PlanEntry {
90    /// The library track this file belongs to, or `None` for a file the library
91    /// doesn't know about. Either way the move is logged and can be undone.
92    pub track_id: Option<i64>,
93    pub from: PathBuf,
94    /// Where the pattern puts it. `None` only when the pattern failed before it
95    /// produced a path at all.
96    pub to: Option<PathBuf>,
97    pub ancillary: Vec<(PathBuf, PathBuf)>,
98    pub outcome: PlanOutcome,
99}
100
101impl PlanEntry {
102    /// Where this file is headed. Only call it on an entry that has a
103    /// destination — a plan that failed before producing one has none.
104    #[cfg(test)]
105    fn dest(&self) -> &Path {
106        self.to.as_deref().expect("plan entry has no destination")
107    }
108
109    /// The executable move this entry stands for, if it is one.
110    fn as_move(&self) -> Option<FileMove> {
111        match (&self.outcome, &self.to) {
112            (PlanOutcome::Move, Some(to)) => Some(FileMove {
113                track_id: self.track_id,
114                from: self.from.clone(),
115                to: to.clone(),
116                ancillary: self.ancillary.clone(),
117            }),
118            _ => None,
119        }
120    }
121}
122
123/// Every selected file and what happens to it, in the order it was planned.
124///
125/// One ordered list rather than separate buckets: a preview is a table with a
126/// row per file, and splitting the failures out of it loses both their place in
127/// the run and the destination they were headed for.
128#[derive(Debug, Default)]
129pub struct OrganizeResult {
130    pub entries: Vec<PlanEntry>,
131}
132
133impl OrganizeResult {
134    pub fn moves(&self) -> impl Iterator<Item = &PlanEntry> {
135        self.entries
136            .iter()
137            .filter(|e| e.outcome == PlanOutcome::Move)
138    }
139
140    pub fn moved_count(&self) -> usize {
141        self.moves().count()
142    }
143
144    /// Files already where the pattern puts them.
145    pub fn unchanged_count(&self) -> usize {
146        self.entries
147            .iter()
148            .filter(|e| e.outcome == PlanOutcome::Unchanged)
149            .count()
150    }
151
152    pub fn conflicts(&self) -> impl Iterator<Item = &PlanEntry> {
153        self.entries
154            .iter()
155            .filter(|e| matches!(e.outcome, PlanOutcome::Conflict(_)))
156    }
157
158    /// Everything that isn't happening and isn't already right — conflicts and
159    /// errors together, since a caller reporting failures wants both.
160    pub fn failures(&self) -> impl Iterator<Item = &PlanEntry> {
161        self.entries
162            .iter()
163            .filter(|e| matches!(e.outcome, PlanOutcome::Conflict(_) | PlanOutcome::Error(_)))
164    }
165
166    /// One line per failure, for callers that render a flat list. Includes the
167    /// destination where there was one — for a conflict that is the whole point.
168    pub fn failure_messages(&self) -> Vec<String> {
169        self.failures()
170            .map(|e| {
171                let reason = e.outcome.reason().unwrap_or("unknown");
172                match &e.to {
173                    Some(to) => format!("{}: {reason} ({})", e.from.display(), to.display()),
174                    None => format!("{}: {reason}", e.from.display()),
175                }
176            })
177            .collect()
178    }
179}
180
181/// An executable move, extracted from a plan entry that can proceed.
182#[derive(Debug)]
183pub struct FileMove {
184    pub track_id: Option<i64>,
185    pub from: PathBuf,
186    pub to: PathBuf,
187    pub ancillary: Vec<(PathBuf, PathBuf)>,
188}
189
190/// One `organize_log` row: id, original path, moved-to path, and the size and
191/// modification time the file had when it was moved.
192type UndoEntry = (i64, String, String, Option<i64>, Option<i64>);
193
194#[derive(Debug, Default)]
195pub struct UndoResult {
196    pub restored: usize,
197    pub errors: Vec<(PathBuf, String)>,
198}
199
200/// Which files an organize run covers.
201enum Selection<'a> {
202    All,
203    TrackIds(&'a [i64]),
204    Paths(&'a [PathBuf]),
205}
206
207/// Album fields a track inherits: both come from the album row, not the track row.
208#[derive(Default, Clone)]
209struct AlbumFacts {
210    date: Option<String>,
211    label: Option<String>,
212}
213
214/// A source file with the metadata its destination will be built from.
215struct ResolvedTrack {
216    source: PathBuf,
217    track_id: Option<i64>,
218    metadata: Result<TrackMetadata, String>,
219}
220
221/// Metadata provider backed by a HashMap, for evaluating format strings against track data.
222struct TrackMetadata {
223    fields: HashMap<String, String>,
224}
225
226impl TrackMetadata {
227    fn from_track_row(track: &TrackRow, album: &AlbumFacts) -> Self {
228        let mut fields = HashMap::new();
229        // Sanitize all field values so they can't inject path separators or illegal chars.
230        let s = sanitise_filename;
231        fields.insert("title".into(), s(&track.title));
232        fields.insert("artist".into(), s(&track.artist_name));
233        fields.insert("album artist".into(), s(&track.album_artist_name));
234        fields.insert("album".into(), s(&track.album_title));
235        if let Some(n) = track.track_number {
236            fields.insert("tracknumber".into(), format!("{n:02}"));
237        }
238        if let Some(d) = track.disc {
239            fields.insert("discnumber".into(), d.to_string());
240        }
241        if let Some(ref date) = album.date {
242            fields.insert("date".into(), s(date));
243        }
244        if let Some(ref label) = album.label {
245            fields.insert("label".into(), s(label));
246        }
247        if let Some(ref codec) = track.codec {
248            fields.insert("codec".into(), s(codec));
249        }
250        if let Some(ref genre) = track.genre {
251            fields.insert("genre".into(), s(genre));
252        }
253        Self { fields }
254    }
255
256    /// Build metadata directly from file tags, for files the library doesn't know about.
257    /// Populates exactly the same field set as `from_track_row` so a preview and the
258    /// move it authorises can never resolve to different paths.
259    fn from_file_meta(meta: &queries::TrackMeta) -> Self {
260        let mut fields = HashMap::new();
261        let s = sanitise_filename;
262        fields.insert("title".into(), s(&meta.title));
263        fields.insert("artist".into(), s(&meta.artist));
264        fields.insert(
265            "album artist".into(),
266            s(meta.album_artist.as_deref().unwrap_or(&meta.artist)),
267        );
268        fields.insert("album".into(), s(&meta.album));
269        if let Some(n) = meta.track_number {
270            fields.insert("tracknumber".into(), format!("{n:02}"));
271        }
272        if let Some(d) = meta.disc {
273            fields.insert("discnumber".into(), d.to_string());
274        }
275        if let Some(ref date) = meta.date {
276            fields.insert("date".into(), s(date));
277        }
278        if let Some(ref label) = meta.label {
279            fields.insert("label".into(), s(label));
280        }
281        if let Some(ref codec) = meta.codec {
282            fields.insert("codec".into(), s(codec));
283        }
284        if let Some(ref genre) = meta.genre {
285            fields.insert("genre".into(), s(genre));
286        }
287        Self { fields }
288    }
289}
290
291impl MetadataProvider for TrackMetadata {
292    fn get_field(&self, name: &str) -> Option<String> {
293        self.fields.get(name).cloned()
294    }
295}
296
297/// Sanitize each component of a relative path independently.
298///
299/// An empty, `.` or `..` component is an error, not something to skip: dropping one
300/// silently collapses a whole album onto a single filename, and the tracks that land
301/// there overwrite each other.
302fn sanitize_relative_path(rel: &str) -> Result<PathBuf, String> {
303    let mut result = PathBuf::new();
304    for part in rel.split(['/', std::path::MAIN_SEPARATOR]) {
305        // Checked before sanitising, which would turn these into `_`.
306        if matches!(part.trim(), "." | "..") {
307            return Err(format!(
308                "format string produced a relative path component: {rel:?}"
309            ));
310        }
311        let sanitized = sanitise_filename(part);
312        if sanitized.is_empty() {
313            return Err(format!(
314                "format string produced an empty path component: {rel:?}"
315            ));
316        }
317        result.push(sanitized);
318    }
319    if result.as_os_str().is_empty() {
320        return Err("format string produced an empty path".into());
321    }
322    Ok(result)
323}
324
325/// Load every album's date and label in one query — both are fields a format string
326/// can reference, and both live on the album row.
327fn load_album_facts(conn: &Connection) -> Result<HashMap<i64, AlbumFacts>, OrganizeError> {
328    let mut stmt = conn.prepare("SELECT id, date, label FROM albums")?;
329    let rows = stmt.query_map([], |row| {
330        Ok((
331            row.get::<_, i64>(0)?,
332            AlbumFacts {
333                date: row.get(1)?,
334                label: row.get(2)?,
335            },
336        ))
337    })?;
338    let mut map = HashMap::new();
339    for row in rows {
340        let (id, facts) = row?;
341        map.insert(id, facts);
342    }
343    Ok(map)
344}
345
346/// Find ancillary files in the same directory as a track.
347fn find_ancillary_files(track_dir: &Path) -> Vec<PathBuf> {
348    let mut files = Vec::new();
349    let Ok(entries) = std::fs::read_dir(track_dir) else {
350        return files;
351    };
352
353    for entry in entries.flatten() {
354        let path = entry.path();
355        if !path.is_file() {
356            continue;
357        }
358        let name = path
359            .file_name()
360            .and_then(|n| n.to_str())
361            .unwrap_or_default()
362            .to_lowercase();
363
364        // Check exact name matches.
365        if ANCILLARY_PATTERNS.iter().any(|p| name == *p) {
366            files.push(path);
367            continue;
368        }
369        // Check extension matches.
370        if let Some(ext) = path.extension().and_then(|e| e.to_str())
371            && ANCILLARY_EXTENSIONS
372                .iter()
373                .any(|e| ext.eq_ignore_ascii_case(e))
374        {
375            files.push(path);
376        }
377    }
378    files.sort();
379    files
380}
381
382/// The destination names a run has already committed to, so two files can never be
383/// planned onto the same path.
384#[derive(Default)]
385struct DestinationLedger {
386    taken: HashSet<String>,
387}
388
389impl DestinationLedger {
390    /// macOS, iOS and Windows filesystems are case-insensitive by default, so
391    /// `Rain.flac` and `RAIN.flac` are one file there and must collide here too.
392    fn key(path: &Path) -> String {
393        let key = path.to_string_lossy().into_owned();
394        if cfg!(any(
395            target_os = "macos",
396            target_os = "ios",
397            target_os = "windows"
398        )) {
399            key.to_lowercase()
400        } else {
401            key
402        }
403    }
404
405    /// Returns false if this destination is already spoken for.
406    fn claim(&mut self, path: &Path) -> bool {
407        self.taken.insert(Self::key(path))
408    }
409}
410
411/// Plan one file: format the pattern, sanitize it into a path, and decide
412/// whether the file can actually go there.
413///
414/// Always yields an entry. A file that can't move is still a row in the plan,
415/// carrying the destination it was headed for and why it isn't going.
416fn plan_single_move(
417    source: &Path,
418    track_id: Option<i64>,
419    metadata: &TrackMetadata,
420    pattern: &str,
421    base_dir: &Path,
422    dests: &mut DestinationLedger,
423) -> PlanEntry {
424    let entry = |to: Option<PathBuf>, outcome: PlanOutcome| PlanEntry {
425        track_id,
426        from: source.to_path_buf(),
427        to,
428        ancillary: Vec::new(),
429        outcome,
430    };
431    // Everything before a destination exists is an error with nothing to point at.
432    macro_rules! bail {
433        ($reason:expr) => {
434            return entry(None, PlanOutcome::Error($reason))
435        };
436    }
437
438    let relative = match format::format(pattern, metadata) {
439        Ok(r) => r,
440        Err(e) => bail!(format!("format error: {e}")),
441    };
442
443    if relative.is_empty() {
444        bail!("format string produced empty path".to_string());
445    }
446
447    let sanitized = match sanitize_relative_path(&relative) {
448        Ok(p) => p,
449        Err(e) => bail!(e),
450    };
451
452    // Preserve the original file extension.
453    // Don't use with_extension() — it replaces after the LAST dot, which
454    // destroys titles containing dots (e.g. "0111. Bicep - TANGZ II" → "0111.flac").
455    let ext = source
456        .extension()
457        .and_then(|e| e.to_str())
458        .unwrap_or("flac");
459    let Some(stem) = sanitized.file_name().and_then(|n| n.to_str()) else {
460        bail!("format string produced an unusable file name".to_string());
461    };
462    // Leave room for the extension, so a long title is shortened rather than
463    // previewing cleanly and failing with ENAMETOOLONG at move time.
464    let stem = truncate_bytes(stem, MAX_FILE_NAME_BYTES.saturating_sub(ext.len() + 1)).trim_end();
465    if stem.is_empty() {
466        bail!("format string produced an empty file name".to_string());
467    }
468    let mut dest = base_dir.to_path_buf();
469    if let Some(parent) = sanitized.parent() {
470        dest.push(parent);
471    }
472    dest.push(format!("{stem}.{ext}"));
473
474    // Safety: verify dest stays under base_dir (defense-in-depth against path traversal).
475    if !dest.starts_with(base_dir) {
476        let reason = format!(
477            "path traversal blocked: destination {} escapes base dir {}",
478            dest.display(),
479            base_dir.display()
480        );
481        return entry(Some(dest), PlanOutcome::Error(reason));
482    }
483
484    if source == dest {
485        // Already in place — claim the name anyway so nothing else targets it.
486        dests.claim(&dest);
487        return entry(Some(dest), PlanOutcome::Unchanged);
488    }
489
490    if !dests.claim(&dest) {
491        return entry(
492            Some(dest),
493            PlanOutcome::Conflict("another file in this run is already going here".into()),
494        );
495    }
496
497    // Whether something is *already* sitting at the destination is a question
498    // for the filesystem, and this function deliberately does not ask one —
499    // see `check_against_disk`.
500    PlanEntry {
501        track_id,
502        from: source.to_path_buf(),
503        to: Some(dest),
504        ancillary: Vec::new(),
505        outcome: PlanOutcome::Move,
506    }
507}
508
509/// Ask the filesystem the two questions formatting cannot answer: whether a
510/// destination is already occupied, and what ancillary files travel with each
511/// move.
512///
513/// Separate from planning because it is the only part that touches the disk. A
514/// preview that reruns on every keystroke wants the pure half immediately and
515/// this afterwards; an execute wants both before it moves anything.
516pub fn check_against_disk(result: &mut OrganizeResult, move_ancillary: bool) {
517    let mut dests = DestinationLedger::default();
518    let mut planned_ancillary: HashSet<PathBuf> = HashSet::new();
519    // One directory read per source folder. An album is one folder and a dozen
520    // tracks, so doing this per file would repeat the same readdir a dozen times.
521    let mut ancillary_by_dir: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
522
523    for entry in &mut result.entries {
524        let (PlanOutcome::Move, Some(dest)) = (&entry.outcome, entry.to.clone()) else {
525            continue;
526        };
527
528        // A destination that resolves to the source itself is a case-only
529        // rename, which is a real move; anything else already there would be
530        // overwritten.
531        if dest.exists() && !paths_equal(&entry.from, &dest) {
532            entry.outcome =
533                PlanOutcome::Conflict("a file is already here — it would be overwritten".into());
534            continue;
535        }
536        dests.claim(&dest);
537
538        if !move_ancillary {
539            continue;
540        }
541        let source_dir = entry.from.parent().unwrap_or(Path::new("."));
542        let dest_dir = dest.parent().unwrap_or(Path::new("."));
543        if source_dir == dest_dir {
544            continue;
545        }
546        let candidates = ancillary_by_dir
547            .entry(source_dir.to_path_buf())
548            .or_insert_with(|| find_ancillary_files(source_dir))
549            .clone();
550        for anc_path in candidates {
551            if planned_ancillary.contains(&anc_path) {
552                continue;
553            }
554            let Some(anc_name) = anc_path.file_name() else {
555                continue;
556            };
557            let anc_dest = dest_dir.join(anc_name);
558            // Artwork already at the destination is left alone rather than
559            // overwritten; the audio file is what matters here.
560            if anc_dest.exists() || !dests.claim(&anc_dest) {
561                continue;
562            }
563            planned_ancillary.insert(anc_path.clone());
564            entry.ancillary.push((anc_path, anc_dest));
565        }
566    }
567}
568
569fn resolve_from_rows(rows: Vec<TrackRow>, albums: &HashMap<i64, AlbumFacts>) -> Vec<ResolvedTrack> {
570    let fallback = AlbumFacts::default();
571    rows.into_iter()
572        .filter_map(|track| {
573            let source = PathBuf::from(track.path.as_ref()?);
574            if !source.exists() {
575                return None; // file gone, skip
576            }
577            let facts = track
578                .album_id
579                .and_then(|id| albums.get(&id))
580                .unwrap_or(&fallback);
581            Some(ResolvedTrack {
582                source,
583                track_id: Some(track.id),
584                metadata: Ok(TrackMetadata::from_track_row(&track, facts)),
585            })
586        })
587        .collect()
588}
589
590fn read_tag_metadata(source: &Path) -> Result<TrackMetadata, String> {
591    if !source.exists() {
592        return Err("file not found".to_string());
593    }
594    crate::index::metadata::read_metadata(source)
595        .map(|m| TrackMetadata::from_file_meta(&m))
596        .map_err(|e| format!("metadata error: {e}"))
597}
598
599/// Resolve arbitrary paths: library rows where we have them, file tags otherwise.
600/// Preview and execute both come through here, so both see the same metadata.
601fn resolve_from_paths(
602    db: &Database,
603    paths: &[PathBuf],
604    albums: &HashMap<i64, AlbumFacts>,
605) -> Result<Vec<ResolvedTrack>, OrganizeError> {
606    use rayon::prelude::*;
607
608    let path_strings: Vec<String> = paths
609        .iter()
610        .map(|p| p.to_string_lossy().into_owned())
611        .collect();
612    let known = queries::tracks_by_paths(&db.conn, &path_strings)?;
613
614    // Tag reads are the expensive part, so only the unknown files pay for them.
615    let mut tagged: HashMap<PathBuf, Result<TrackMetadata, String>> = paths
616        .par_iter()
617        .filter(|p| !known.contains_key(p.to_string_lossy().as_ref()))
618        .map(|p| (p.clone(), read_tag_metadata(p)))
619        .collect();
620
621    let fallback = AlbumFacts::default();
622    let mut resolved = Vec::with_capacity(paths.len());
623    for (path, path_str) in paths.iter().zip(&path_strings) {
624        let entry = match known.get(path_str) {
625            Some(track) => {
626                let facts = track
627                    .album_id
628                    .and_then(|id| albums.get(&id))
629                    .unwrap_or(&fallback);
630                ResolvedTrack {
631                    source: path.clone(),
632                    track_id: Some(track.id),
633                    metadata: Ok(TrackMetadata::from_track_row(track, facts)),
634                }
635            }
636            None => ResolvedTrack {
637                source: path.clone(),
638                track_id: None,
639                metadata: tagged
640                    .remove(path)
641                    .unwrap_or_else(|| Err("duplicate path in selection".to_string())),
642            },
643        };
644        resolved.push(entry);
645    }
646    Ok(resolved)
647}
648
649/// A selection with every read already done: library rows, album facts, and
650/// tags for files the library has never seen.
651///
652/// This is the half that costs something. Generating destinations from it is
653/// pure string work, so a preview that reruns as a pattern is typed resolves
654/// once here and formats many times against the result.
655pub struct ResolvedSelection {
656    tracks: Vec<ResolvedTrack>,
657}
658
659impl ResolvedSelection {
660    /// How many files resolved to something with a local path. Fewer than were
661    /// asked for means the rest are remote-only or gone from disk.
662    pub fn len(&self) -> usize {
663        self.tracks.len()
664    }
665
666    pub fn is_empty(&self) -> bool {
667        self.tracks.is_empty()
668    }
669}
670
671/// Read a selection out of the library. `track_ids` of `None` means all of it.
672///
673/// Database reads and a `stat` per file, so it belongs off whatever thread is
674/// drawing — but it only has to happen once per selection.
675pub fn resolve(
676    db: &Database,
677    track_ids: Option<&[i64]>,
678) -> Result<ResolvedSelection, OrganizeError> {
679    let selection = match track_ids {
680        Some(ids) => Selection::TrackIds(ids),
681        None => Selection::All,
682    };
683    resolve_selection(db, selection)
684}
685
686fn resolve_selection(
687    db: &Database,
688    selection: Selection<'_>,
689) -> Result<ResolvedSelection, OrganizeError> {
690    let albums = load_album_facts(&db.conn)?;
691    let tracks = match selection {
692        Selection::All => resolve_from_rows(queries::all_tracks(&db.conn)?, &albums),
693        Selection::TrackIds(ids) => {
694            let mut rows = Vec::with_capacity(ids.len());
695            for &id in ids {
696                if let Some(row) = queries::get_track_row(&db.conn, id)? {
697                    rows.push(row);
698                }
699            }
700            resolve_from_rows(rows, &albums)
701        }
702        Selection::Paths(paths) => resolve_from_paths(db, paths, &albums)?,
703    };
704    Ok(ResolvedSelection { tracks })
705}
706
707/// Turn a pattern into destinations. **Touches no files at all.**
708///
709/// Everything here is formatting the pattern, sanitising what it produced, and
710/// checking the result against the destinations this same run has already
711/// claimed. That is fast enough to run on every keystroke, which is the whole
712/// reason it is separate from `check_against_disk`.
713pub fn generate(selection: &ResolvedSelection, pattern: &str, base_dir: &Path) -> OrganizeResult {
714    let mut entries = Vec::with_capacity(selection.tracks.len());
715    let mut dests = DestinationLedger::default();
716
717    for track in &selection.tracks {
718        let metadata = match &track.metadata {
719            Ok(m) => m,
720            Err(msg) => {
721                entries.push(PlanEntry {
722                    track_id: track.track_id,
723                    from: track.source.clone(),
724                    to: None,
725                    ancillary: Vec::new(),
726                    outcome: PlanOutcome::Error(msg.clone()),
727                });
728                continue;
729            }
730        };
731        entries.push(plan_single_move(
732            &track.source,
733            track.track_id,
734            metadata,
735            pattern,
736            base_dir,
737            &mut dests,
738        ));
739    }
740
741    OrganizeResult { entries }
742}
743
744/// Resolve, generate, and optionally ask the disk. Every entry point plans
745/// through here, so a preview and the execute that follows it produce the same
746/// destinations from the same metadata.
747fn plan(
748    db: &Database,
749    selection: Selection<'_>,
750    pattern: &str,
751    base_dir: &Path,
752    check_disk: bool,
753) -> Result<OrganizeResult, OrganizeError> {
754    let resolved = resolve_selection(db, selection)?;
755    let mut result = generate(&resolved, pattern, base_dir);
756    if check_disk {
757        check_against_disk(&mut result, move_ancillary());
758    }
759    Ok(result)
760}
761
762/// Plan, then carry out the moves: each file's database rows and its rename land
763/// together or not at all.
764fn run(
765    db: &Database,
766    selection: Selection<'_>,
767    pattern: &str,
768    base_dir: &Path,
769) -> Result<OrganizeResult, OrganizeError> {
770    let mut result = plan(db, selection, pattern, base_dir, true)?;
771
772    let pending: Vec<FileMove> = result
773        .entries
774        .iter()
775        .filter_map(PlanEntry::as_move)
776        .collect();
777    if pending.is_empty() {
778        return Ok(result);
779    }
780
781    check_free_space(&pending, base_dir)?;
782
783    let batch_id = batch_id();
784    let floors = cleanup_floors(Some(base_dir));
785
786    // The plan is the report: a move that fails has its own row demoted to an
787    // error, so the caller sees the same table it confirmed, now saying what
788    // actually happened to each file.
789    for file_move in pending {
790        let failure = match execute_single_move(db, &file_move, &batch_id, &floors) {
791            Ok(()) => verify_move(&file_move).err(),
792            Err(e) => Some(e.to_string()),
793        };
794        let Some(reason) = failure else { continue };
795        log::warn!(
796            "organize: {} → {} failed: {reason}",
797            file_move.from.display(),
798            file_move.to.display()
799        );
800        if let Some(entry) = result.entries.iter_mut().find(|e| e.from == file_move.from) {
801            entry.outcome = PlanOutcome::Error(reason);
802        }
803    }
804
805    Ok(result)
806}
807
808/// Preview what would happen without moving files.
809///
810/// `check_disk` is what finds destinations that are already occupied and the
811/// ancillary files travelling with each move. It is a `stat` per file and a
812/// directory read per source folder, and it changes none of the destinations —
813/// so a preview that reruns as a pattern is typed leaves it off and fills it in
814/// afterwards.
815pub fn preview(
816    db: &Database,
817    pattern: &str,
818    base_dir: Option<&Path>,
819    check_disk: bool,
820) -> Result<OrganizeResult, OrganizeError> {
821    let base = resolve_base_dir(base_dir)?;
822    plan(db, Selection::All, pattern, &base, check_disk)
823}
824
825/// Execute the moves: rename files, update DB, log for undo.
826pub fn execute(
827    db: &Database,
828    pattern: &str,
829    base_dir: Option<&Path>,
830) -> Result<OrganizeResult, OrganizeError> {
831    let base = resolve_base_dir(base_dir)?;
832    run(db, Selection::All, pattern, &base)
833}
834
835/// Preview organize for a specific set of tracks.
836pub fn preview_for_tracks(
837    db: &Database,
838    track_ids: &[i64],
839    pattern: &str,
840    base_dir: Option<&Path>,
841    check_disk: bool,
842) -> Result<OrganizeResult, OrganizeError> {
843    let base = resolve_base_dir(base_dir)?;
844    plan(
845        db,
846        Selection::TrackIds(track_ids),
847        pattern,
848        &base,
849        check_disk,
850    )
851}
852
853/// Execute organize for a specific set of tracks.
854pub fn execute_for_tracks(
855    db: &Database,
856    track_ids: &[i64],
857    pattern: &str,
858    base_dir: Option<&Path>,
859) -> Result<OrganizeResult, OrganizeError> {
860    let base = resolve_base_dir(base_dir)?;
861    run(db, Selection::TrackIds(track_ids), pattern, &base)
862}
863
864/// Preview organize for file paths, which may or may not be in the library.
865pub fn preview_for_paths(
866    paths: &[PathBuf],
867    pattern: &str,
868    base_dir: Option<&Path>,
869    check_disk: bool,
870) -> Result<OrganizeResult, OrganizeError> {
871    let db = crate::db::pool::shared().get()?;
872    let base = resolve_base_dir(base_dir)?;
873    plan(&db, Selection::Paths(paths), pattern, &base, check_disk)
874}
875
876/// Execute organize for file paths. Requires the library database: without it there is
877/// nowhere to record the moves, and an organize that can't be undone isn't offered.
878pub fn execute_for_paths(
879    paths: &[PathBuf],
880    pattern: &str,
881    base_dir: Option<&Path>,
882) -> Result<OrganizeResult, OrganizeError> {
883    let db = crate::db::pool::shared().get()?;
884    let base = resolve_base_dir(base_dir)?;
885    run(&db, Selection::Paths(paths), pattern, &base)
886}
887
888/// Verify a move actually happened — dest exists and source is gone.
889fn verify_move(file_move: &FileMove) -> Result<(), String> {
890    if !file_move.to.exists() {
891        return Err(format!(
892            "destination not found after move: {}",
893            file_move.to.display()
894        ));
895    }
896    if file_move.from.exists() && !paths_equal(&file_move.from, &file_move.to) {
897        return Err(format!(
898            "source still exists after move: {}",
899            file_move.from.display()
900        ));
901    }
902    Ok(())
903}
904
905fn log_move(
906    conn: &Connection,
907    batch_id: &str,
908    track_id: Option<i64>,
909    from: &Path,
910    to: &Path,
911    size: Option<u64>,
912    mtime: Option<i64>,
913) -> Result<(), OrganizeError> {
914    conn.execute(
915        "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, size_bytes, mtime)
916         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
917        params![
918            batch_id,
919            track_id,
920            from.to_string_lossy().as_ref(),
921            to.to_string_lossy().as_ref(),
922            size.map(|s| s as i64),
923            mtime,
924        ],
925    )?;
926    Ok(())
927}
928
929/// Point every path-keyed row at the file's new location.
930///
931/// `tracks.path` and `scan_cache.path` are UNIQUE, so a move onto a path another row
932/// already claims fails here — inside the caller's transaction, before the file itself
933/// is touched.
934fn rewrite_path_references(conn: &Connection, old: &Path, new: &Path) -> Result<(), OrganizeError> {
935    let old_lossy = old.to_string_lossy();
936    let new_lossy = new.to_string_lossy();
937    let old_path = old_lossy.as_ref();
938    let new_path = new_lossy.as_ref();
939
940    conn.execute(
941        "UPDATE tracks SET path = ?1 WHERE path = ?2",
942        params![new_path, old_path],
943    )?;
944    crate::db::queries::sources::rename_file(conn, old_path, new_path)?;
945    conn.execute(
946        "UPDATE tracks SET cached_path = ?1 WHERE cached_path = ?2",
947        params![new_path, old_path],
948    )?;
949    conn.execute(
950        "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
951        params![new_path, old_path],
952    )?;
953    conn.execute(
954        "UPDATE playback_position SET cursor_id = ?1 WHERE cursor_id = ?2",
955        params![new_path, old_path],
956    )?;
957    rewrite_queue_json(conn, old_path, new_path)?;
958    Ok(())
959}
960
961/// Rewrite paths inside the saved session's serialized queue.
962///
963/// Playlists need no equivalent: they point at library rows, and a row's path
964/// changing is a column this function has already updated.
965fn rewrite_queue_json(
966    conn: &Connection,
967    old_path: &str,
968    new_path: &str,
969) -> Result<(), OrganizeError> {
970    let mut stmt =
971        conn.prepare("SELECT id, queue_json FROM playback_state WHERE instr(queue_json, ?1) > 0")?;
972    let rows: Vec<(i64, String)> = stmt
973        .query_map(params![old_path], |row| Ok((row.get(0)?, row.get(1)?)))?
974        .collect::<Result<Vec<_>, _>>()?;
975    drop(stmt);
976
977    for (id, json) in rows {
978        let Ok(mut items) = serde_json::from_str::<Vec<PersistedQueueItem>>(&json) else {
979            continue;
980        };
981        let mut changed = false;
982        for item in &mut items {
983            if item.path == old_path {
984                item.path = new_path.to_string();
985                changed = true;
986            }
987        }
988        if !changed {
989            continue;
990        }
991        let Ok(updated) = serde_json::to_string(&items) else {
992            continue;
993        };
994        conn.execute(
995            "UPDATE playback_state SET queue_json = ?1 WHERE id = ?2",
996            params![updated, id],
997        )?;
998    }
999    Ok(())
1000}
1001
1002/// Execute a single file move: write the database rows first, then move the file.
1003/// A constraint violation therefore aborts before anything on disk changes, and a
1004/// failed rename rolls the rows back.
1005fn execute_single_move(
1006    db: &Database,
1007    file_move: &FileMove,
1008    batch_id: &str,
1009    floors: &[PathBuf],
1010) -> Result<(), OrganizeError> {
1011    if let Some(parent) = file_move.to.parent() {
1012        std::fs::create_dir_all(parent)?;
1013    }
1014
1015    let source_meta = std::fs::metadata(&file_move.from)?;
1016    let size = source_meta.len();
1017    let mtime = mtime_secs(&source_meta);
1018
1019    let tx = crate::db::queries::write_transaction(&db.conn)?;
1020    log_move(
1021        &tx,
1022        batch_id,
1023        file_move.track_id,
1024        &file_move.from,
1025        &file_move.to,
1026        Some(size),
1027        mtime,
1028    )?;
1029    rewrite_path_references(&tx, &file_move.from, &file_move.to)?;
1030
1031    // Dropping `tx` on the way out of this `?` rolls the rows back.
1032    move_file(&file_move.from, &file_move.to)?;
1033
1034    let mut moved_ancillary: Vec<(&PathBuf, &PathBuf)> = Vec::new();
1035    let mut failure = None;
1036    for (anc_from, anc_to) in &file_move.ancillary {
1037        if let Some(parent) = anc_to.parent()
1038            && std::fs::create_dir_all(parent).is_err()
1039        {
1040            continue;
1041        }
1042        // Best-effort — artwork that won't move doesn't hold up the audio file.
1043        match move_file(anc_from, anc_to) {
1044            Ok(()) => {
1045                moved_ancillary.push((anc_from, anc_to));
1046                let meta = std::fs::metadata(anc_to).ok();
1047                if let Err(e) = log_move(
1048                    &tx,
1049                    batch_id,
1050                    None,
1051                    anc_from,
1052                    anc_to,
1053                    meta.as_ref().map(|m| m.len()),
1054                    meta.as_ref().and_then(mtime_secs),
1055                ) {
1056                    failure = Some(e);
1057                    break;
1058                }
1059            }
1060            Err(e) => log::warn!(
1061                "failed to move ancillary file {}: {}",
1062                anc_from.display(),
1063                e
1064            ),
1065        }
1066    }
1067
1068    let outcome = match failure {
1069        Some(e) => Err(e),
1070        None => tx.commit().map_err(OrganizeError::from),
1071    };
1072
1073    if let Err(e) = outcome {
1074        // The rows rolled back, so nothing records these files as moved and nothing
1075        // could undo them. Put them back.
1076        for (anc_from, anc_to) in moved_ancillary {
1077            let _ = move_file(anc_to, anc_from);
1078        }
1079        let _ = move_file(&file_move.to, &file_move.from);
1080        return Err(e);
1081    }
1082
1083    if let Some(source_dir) = file_move.from.parent() {
1084        remove_empty_dirs(source_dir, floors);
1085    }
1086
1087    Ok(())
1088}
1089
1090/// Undo the most recent organize batch.
1091///
1092/// Each entry is restored only when the original path is still free and the moved file
1093/// is still the one that was logged. Anything else is reported and left in the log, so
1094/// a single blocked file doesn't strand the rest of the batch.
1095pub fn undo(db: &Database) -> Result<UndoResult, OrganizeError> {
1096    // Newest batch by primary key: created_at only has one-second resolution, so two
1097    // batches in the same second would tie.
1098    let batch_id: String = db
1099        .conn
1100        .query_row(
1101            "SELECT batch_id FROM organize_log ORDER BY id DESC LIMIT 1",
1102            [],
1103            |row| row.get(0),
1104        )
1105        .map_err(|_| OrganizeError::NothingToUndo)?;
1106
1107    let mut stmt = db.conn.prepare(
1108        "SELECT id, from_path, to_path, size_bytes, mtime FROM organize_log
1109         WHERE batch_id = ?1 ORDER BY id DESC",
1110    )?;
1111
1112    let entries: Vec<UndoEntry> = stmt
1113        .query_map(params![batch_id], |row| {
1114            Ok((
1115                row.get(0)?,
1116                row.get(1)?,
1117                row.get(2)?,
1118                row.get(3)?,
1119                row.get(4)?,
1120            ))
1121        })?
1122        .collect::<Result<Vec<_>, _>>()?;
1123    drop(stmt);
1124
1125    let floors = cleanup_floors(None);
1126    let mut result = UndoResult::default();
1127
1128    for (log_id, from_path, to_path, size, mtime) in &entries {
1129        let to = Path::new(to_path);
1130        let from = Path::new(from_path);
1131
1132        if !to.exists() {
1133            // Already moved back or deleted — drop the log row.
1134            db.conn
1135                .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
1136            continue;
1137        }
1138
1139        if from.exists() && !paths_equal(from, to) {
1140            result.errors.push((
1141                from.to_path_buf(),
1142                format!(
1143                    "another file now occupies the original path; {} left in place",
1144                    to.display()
1145                ),
1146            ));
1147            continue;
1148        }
1149
1150        if let Err(msg) = matches_logged_file(to, *size, *mtime) {
1151            result.errors.push((to.to_path_buf(), msg));
1152            continue;
1153        }
1154
1155        if let Some(parent) = from.parent()
1156            && let Err(e) = std::fs::create_dir_all(parent)
1157        {
1158            result.errors.push((from.to_path_buf(), e.to_string()));
1159            continue;
1160        }
1161
1162        let tx = crate::db::queries::write_transaction(&db.conn)?;
1163        if let Err(e) = rewrite_path_references(&tx, to, from) {
1164            result.errors.push((to.to_path_buf(), e.to_string()));
1165            continue;
1166        }
1167        if let Err(e) = move_file(to, from) {
1168            result.errors.push((to.to_path_buf(), e.to_string()));
1169            continue;
1170        }
1171        if let Err(e) = tx.execute("DELETE FROM organize_log WHERE id = ?1", params![log_id]) {
1172            let _ = move_file(from, to);
1173            result.errors.push((to.to_path_buf(), e.to_string()));
1174            continue;
1175        }
1176        if let Err(e) = tx.commit() {
1177            let _ = move_file(from, to);
1178            result.errors.push((to.to_path_buf(), e.to_string()));
1179            continue;
1180        }
1181
1182        if let Some(parent) = to.parent() {
1183            remove_empty_dirs(parent, &floors);
1184        }
1185
1186        result.restored += 1;
1187    }
1188
1189    Ok(result)
1190}
1191
1192/// Confirm the file at a logged destination is still the file that was moved there.
1193/// Rows written before size/mtime were recorded carry neither and are accepted.
1194fn matches_logged_file(path: &Path, size: Option<i64>, mtime: Option<i64>) -> Result<(), String> {
1195    let (Some(size), Some(mtime)) = (size, mtime) else {
1196        return Ok(());
1197    };
1198    let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
1199    if meta.len() != size as u64 {
1200        return Err(format!(
1201            "{} has changed since it was moved (size differs); left in place",
1202            path.display()
1203        ));
1204    }
1205    if mtime_secs(&meta).is_some_and(|current| current != mtime) {
1206        return Err(format!(
1207            "{} has changed since it was moved (modification time differs); left in place",
1208            path.display()
1209        ));
1210    }
1211    Ok(())
1212}
1213
1214fn mtime_secs(meta: &std::fs::Metadata) -> Option<i64> {
1215    meta.modified()
1216        .ok()?
1217        .duration_since(UNIX_EPOCH)
1218        .ok()
1219        .map(|d| d.as_secs() as i64)
1220}
1221
1222/// Directories an empty-directory sweep must never remove or climb past.
1223fn cleanup_floors(base: Option<&Path>) -> Vec<PathBuf> {
1224    let mut floors: Vec<PathBuf> = base.map(Path::to_path_buf).into_iter().collect();
1225    if let Ok(config) = crate::config::Config::load() {
1226        floors.extend(config.library.folders);
1227    }
1228    floors
1229}
1230
1231/// Remove the directory a file just left, and its now-empty parents — but never a
1232/// configured library root, and never anything above one.
1233fn remove_empty_dirs(start: &Path, floors: &[PathBuf]) {
1234    let mut current = start.to_path_buf();
1235    loop {
1236        if floors.iter().any(|floor| floor == &current) {
1237            break;
1238        }
1239        let empty = std::fs::read_dir(&current)
1240            .map(|mut d| d.next().is_none())
1241            .unwrap_or(false);
1242        if !empty || std::fs::remove_dir(&current).is_err() {
1243            break;
1244        }
1245        let Some(parent) = current.parent() else {
1246            break;
1247        };
1248        // Only keep climbing inside a directory the run was told about.
1249        if !floors
1250            .iter()
1251            .any(|floor| parent.starts_with(floor) && parent != floor.as_path())
1252        {
1253            break;
1254        }
1255        current = parent.to_path_buf();
1256    }
1257}
1258
1259/// Move a file, never overwriting whatever is already at the destination.
1260fn move_file(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1261    if from == to {
1262        return Ok(());
1263    }
1264    if paths_equal(from, to) {
1265        // Same file under a different spelling — a case-only rename on a
1266        // case-insensitive filesystem. Reserving the destination would land on
1267        // the source itself, so it goes via a temporary name.
1268        return rename_via_temp(from, to);
1269    }
1270
1271    // Claim the name atomically: nothing can slip into the destination between
1272    // this check and the rename below.
1273    match std::fs::OpenOptions::new()
1274        .write(true)
1275        .create_new(true)
1276        .open(to)
1277    {
1278        Ok(_) => {}
1279        Err(e) if e.kind() == ErrorKind::AlreadyExists => {
1280            return Err(OrganizeError::DestinationExists(to.to_path_buf()));
1281        }
1282        Err(e) => return Err(e.into()),
1283    }
1284
1285    match transfer(from, to) {
1286        Ok(()) => Ok(()),
1287        Err(e) => {
1288            // Don't leave the empty placeholder behind.
1289            let _ = std::fs::remove_file(to);
1290            Err(e)
1291        }
1292    }
1293}
1294
1295fn rename_via_temp(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1296    let temp = temp_sibling(to);
1297    std::fs::rename(from, &temp)?;
1298    match std::fs::rename(&temp, to) {
1299        Ok(()) => Ok(()),
1300        Err(e) => {
1301            let _ = std::fs::rename(&temp, from);
1302            Err(e.into())
1303        }
1304    }
1305}
1306
1307/// Rename, falling back to a verified copy when the destination is on another filesystem.
1308fn transfer(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1309    match std::fs::rename(from, to) {
1310        Ok(()) => Ok(()),
1311        // EXDEV (18): cross-device link.
1312        Err(e) if e.raw_os_error() == Some(18) => copy_across_devices(from, to),
1313        Err(e) => Err(e.into()),
1314    }
1315}
1316
1317/// Copy to a temporary file, flush it to disk, verify its length, and only then
1318/// drop the original. A crash at any point leaves the source intact.
1319fn copy_across_devices(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1320    let source_meta = std::fs::metadata(from)?;
1321    let expected = source_meta.len();
1322    let temp = temp_sibling(to);
1323
1324    let copied = {
1325        let mut reader = std::fs::File::open(from)?;
1326        let mut writer = std::fs::OpenOptions::new()
1327            .write(true)
1328            .create_new(true)
1329            .open(&temp)?;
1330        let copied = std::io::copy(&mut reader, &mut writer)?;
1331        // std::io::copy returning Ok only means the bytes reached the page cache.
1332        writer.sync_all()?;
1333        if let Ok(modified) = source_meta.modified() {
1334            let _ = writer.set_modified(modified);
1335        }
1336        copied
1337    };
1338
1339    let written = std::fs::metadata(&temp).map(|m| m.len()).unwrap_or(0);
1340    if copied != expected || written != expected {
1341        let _ = std::fs::remove_file(&temp);
1342        return Err(OrganizeError::ShortCopy {
1343            path: from.to_path_buf(),
1344            expected,
1345            copied: copied.min(written),
1346        });
1347    }
1348
1349    if let Err(e) = std::fs::rename(&temp, to) {
1350        let _ = std::fs::remove_file(&temp);
1351        return Err(e.into());
1352    }
1353    std::fs::remove_file(from)?;
1354    Ok(())
1355}
1356
1357fn temp_sibling(path: &Path) -> PathBuf {
1358    let nanos = SystemTime::now()
1359        .duration_since(UNIX_EPOCH)
1360        .unwrap_or_default()
1361        .as_nanos();
1362    path.with_file_name(format!(".koan-{}-{}.tmp", std::process::id(), nanos))
1363}
1364
1365/// Compare paths for equality, including two spellings of one file on a
1366/// case-insensitive filesystem.
1367fn paths_equal(a: &Path, b: &Path) -> bool {
1368    if a == b {
1369        return true;
1370    }
1371    #[cfg(unix)]
1372    {
1373        use std::os::unix::fs::MetadataExt;
1374        if let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) {
1375            return ma.dev() == mb.dev() && ma.ino() == mb.ino();
1376        }
1377    }
1378    false
1379}
1380
1381/// Refuse a run that can't fit, rather than discovering it partway through.
1382/// Only files landing on a different filesystem need space.
1383fn check_free_space(moves: &[FileMove], base_dir: &Path) -> Result<(), OrganizeError> {
1384    let Some(target) = existing_ancestor(base_dir) else {
1385        return Ok(());
1386    };
1387    let Some(target_device) = device_id(&target) else {
1388        return Ok(());
1389    };
1390
1391    let mut needed = 0u64;
1392    for file_move in moves {
1393        if device_id(&file_move.from).is_some_and(|d| d == target_device) {
1394            continue;
1395        }
1396        if let Ok(meta) = std::fs::metadata(&file_move.from) {
1397            needed = needed.saturating_add(meta.len());
1398        }
1399    }
1400    if needed == 0 {
1401        return Ok(());
1402    }
1403
1404    match available_bytes(&target) {
1405        Some(available) if available < needed => {
1406            Err(OrganizeError::NotEnoughSpace { needed, available })
1407        }
1408        _ => Ok(()),
1409    }
1410}
1411
1412fn existing_ancestor(path: &Path) -> Option<PathBuf> {
1413    path.ancestors().find(|p| p.exists()).map(Path::to_path_buf)
1414}
1415
1416#[cfg(unix)]
1417fn device_id(path: &Path) -> Option<u64> {
1418    use std::os::unix::fs::MetadataExt;
1419    std::fs::metadata(path).ok().map(|m| m.dev())
1420}
1421
1422#[cfg(not(unix))]
1423fn device_id(_path: &Path) -> Option<u64> {
1424    None
1425}
1426
1427#[cfg(unix)]
1428fn available_bytes(path: &Path) -> Option<u64> {
1429    use std::os::unix::ffi::OsStrExt;
1430    let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1431    let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
1432    if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
1433        return None;
1434    }
1435    // Widths of these fields differ between macOS and Linux.
1436    (stat.f_bavail as u64).checked_mul(stat.f_frsize as u64)
1437}
1438
1439#[cfg(not(unix))]
1440fn available_bytes(_path: &Path) -> Option<u64> {
1441    None
1442}
1443
1444/// Where the pattern's relative paths hang off. `None` uses the first
1445/// configured library folder.
1446///
1447/// An empty path is refused rather than accepted as "here". It makes every
1448/// destination relative to the process's working directory — `/` for an app
1449/// bundle — so the plan formats and previews perfectly and every single move
1450/// then fails at `create_dir_all`.
1451fn resolve_base_dir(base_dir: Option<&Path>) -> Result<PathBuf, OrganizeError> {
1452    let dir = match base_dir {
1453        Some(dir) => dir.to_path_buf(),
1454        None => crate::config::Config::load()
1455            .map_err(|e| OrganizeError::Io(std::io::Error::other(e.to_string())))?
1456            .library
1457            .folders
1458            .into_iter()
1459            .find(|folder| !folder.as_os_str().is_empty())
1460            .ok_or(OrganizeError::NoDestination)?,
1461    };
1462
1463    if dir.as_os_str().is_empty() {
1464        return Err(OrganizeError::NoDestination);
1465    }
1466    Ok(dir)
1467}
1468
1469/// Whether cover art and cue sheets travel with the music. A preference, so it
1470/// is read where it is used rather than threaded through every signature.
1471fn move_ancillary() -> bool {
1472    crate::config::Config::load()
1473        .map(|c| c.organize.move_ancillary)
1474        .unwrap_or(true)
1475}
1476
1477fn batch_id() -> String {
1478    let now = SystemTime::now()
1479        .duration_since(UNIX_EPOCH)
1480        .unwrap_or_default();
1481    format!("batch-{}", now.as_nanos())
1482}
1483
1484#[cfg(test)]
1485mod tests {
1486    use super::*;
1487    use crate::db::queries::TrackMeta;
1488    use crate::db::schema;
1489    use tempfile::TempDir;
1490
1491    fn test_db() -> Database {
1492        let conn = rusqlite::Connection::open_in_memory().unwrap();
1493        conn.pragma_update(None, "foreign_keys", "on").unwrap();
1494        schema::create_tables(&conn).unwrap();
1495        Database { conn }
1496    }
1497
1498    fn sample_meta(title: &str, artist: &str, album: &str) -> TrackMeta {
1499        TrackMeta {
1500            title: title.into(),
1501            artist: artist.into(),
1502            album_artist: Some(artist.into()),
1503            album: album.into(),
1504            date: Some("1997-06-16".into()),
1505            disc: Some(1),
1506            track_number: Some(1),
1507            genre: Some("Rock".into()),
1508            label: None,
1509            duration_ms: Some(240_000),
1510            codec: Some("FLAC".into()),
1511            sample_rate: Some(44100),
1512            bit_depth: Some(16),
1513            channels: Some(2),
1514            bitrate: Some(1000),
1515            size_bytes: Some(30_000_000),
1516            mtime: Some(1700000000),
1517            path: None,
1518            source: "local".into(),
1519            remote_id: None,
1520            remote_url: None,
1521            album_remote_id: None,
1522            artist_remote_id: None,
1523            mbid: None,
1524            album_mbid: None,
1525            album_added_at: None,
1526        }
1527    }
1528
1529    fn sample_track_row(title: &str, artist: &str, album: &str) -> TrackRow {
1530        TrackRow {
1531            id: 1,
1532            album_id: Some(1),
1533            artist_id: Some(1),
1534            artist_name: artist.into(),
1535            album_artist_name: artist.into(),
1536            album_title: album.into(),
1537            disc: Some(1),
1538            track_number: Some(1),
1539            title: title.into(),
1540            duration_ms: Some(240_000),
1541            path: Some("/music/test.flac".into()),
1542            codec: Some("FLAC".into()),
1543            sample_rate: Some(44100),
1544            bit_depth: Some(16),
1545            channels: Some(2),
1546            bitrate: Some(1000),
1547            genre: None,
1548            source: "local".into(),
1549            remote_id: None,
1550            cached_path: None,
1551        }
1552    }
1553
1554    /// Write a file with recognisable contents and register it in the library.
1555    fn add_track(db: &Database, path: &Path, title: &str, track_number: i32) -> i64 {
1556        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1557        std::fs::write(path, format!("audio bytes for {title}")).unwrap();
1558        let mut meta = sample_meta(title, "Radiohead", "OK Computer");
1559        meta.track_number = Some(track_number);
1560        meta.path = Some(path.to_string_lossy().into_owned());
1561        queries::upsert_track(&db.conn, &meta).unwrap()
1562    }
1563
1564    fn db_path_of(db: &Database, track_id: i64) -> Option<String> {
1565        db.conn
1566            .query_row(
1567                "SELECT path FROM tracks WHERE id = ?1",
1568                params![track_id],
1569                |row| row.get(0),
1570            )
1571            .unwrap()
1572    }
1573
1574    fn log_rows(db: &Database) -> Vec<(Option<i64>, String, String)> {
1575        let mut stmt = db
1576            .conn
1577            .prepare("SELECT track_id, from_path, to_path FROM organize_log ORDER BY id")
1578            .unwrap();
1579        let rows = stmt
1580            .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1581            .unwrap();
1582        rows.map(|r| r.unwrap()).collect()
1583    }
1584
1585    // ---- Metadata + sanitisation ----
1586
1587    #[test]
1588    fn track_metadata_provider_fields() {
1589        let mut track = sample_track_row("Subterranean Homesick Alien", "Radiohead", "OK Computer");
1590        track.track_number = Some(3);
1591        track.genre = Some("Alternative".into());
1592
1593        let album = AlbumFacts {
1594            date: Some("1997-06-16".into()),
1595            label: Some("Parlophone".into()),
1596        };
1597        let meta = TrackMetadata::from_track_row(&track, &album);
1598        assert_eq!(
1599            meta.get_field("title").as_deref(),
1600            Some("Subterranean Homesick Alien")
1601        );
1602        assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
1603        assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
1604        assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
1605        assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
1606        assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
1607        assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
1608        assert_eq!(meta.get_field("label").as_deref(), Some("Parlophone"));
1609        assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
1610        assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
1611        assert_eq!(meta.get_field("nonexistent"), None);
1612    }
1613
1614    /// Both providers must populate the same field names, or a preview taken from one
1615    /// authorises a move planned by the other.
1616    #[test]
1617    fn both_metadata_sources_expose_the_same_fields() {
1618        let mut track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1619        track.genre = Some("Rock".into());
1620        let album = AlbumFacts {
1621            date: Some("1997-06-16".into()),
1622            label: Some("Parlophone".into()),
1623        };
1624        let from_db = TrackMetadata::from_track_row(&track, &album);
1625
1626        let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1627        meta.label = Some("Parlophone".into());
1628        let from_tags = TrackMetadata::from_file_meta(&meta);
1629
1630        let mut db_fields: Vec<&String> = from_db.fields.keys().collect();
1631        let mut tag_fields: Vec<&String> = from_tags.fields.keys().collect();
1632        db_fields.sort();
1633        tag_fields.sort();
1634        assert_eq!(db_fields, tag_fields);
1635    }
1636
1637    #[test]
1638    fn sanitize_replaces_illegal_chars() {
1639        assert_eq!(sanitise_filename("AC/DC"), "AC_DC");
1640        assert_eq!(sanitise_filename("What?"), "What_");
1641        assert_eq!(sanitise_filename("a:b*c"), "a_b_c");
1642        assert_eq!(sanitise_filename("normal"), "normal");
1643    }
1644
1645    #[test]
1646    fn sanitize_relative_path_splits() {
1647        assert_eq!(
1648            sanitize_relative_path("Artist/Album/Track").unwrap(),
1649            PathBuf::from("Artist/Album/Track")
1650        );
1651        assert_eq!(
1652            sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag").unwrap(),
1653            PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
1654        );
1655    }
1656
1657    #[test]
1658    fn sanitize_relative_path_refuses_traversal_and_gaps() {
1659        // Reinterpreting these silently is what turns one bad pattern into a
1660        // directory full of overwritten files.
1661        assert!(sanitize_relative_path("../../../../etc/passwd").is_err());
1662        assert!(sanitize_relative_path("Artist/../../../outside").is_err());
1663        assert!(sanitize_relative_path("./Artist/./Album").is_err());
1664        assert!(sanitize_relative_path("Radiohead/OK Computer/").is_err());
1665        assert!(sanitize_relative_path("Radiohead//Airbag").is_err());
1666        assert!(sanitize_relative_path("   /Airbag").is_err());
1667    }
1668
1669    #[test]
1670    fn acdc_artist_name_sanitized() {
1671        let track = sample_track_row("Highway to Hell", "AC/DC", "Highway to Hell");
1672        let meta = TrackMetadata::from_track_row(&track, &AlbumFacts::default());
1673        assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
1674        let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
1675        assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
1676    }
1677
1678    #[test]
1679    fn format_string_evaluation() {
1680        let track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1681        let album = AlbumFacts {
1682            date: Some("1997-06-16".into()),
1683            label: None,
1684        };
1685        let meta = TrackMetadata::from_track_row(&track, &album);
1686        let pattern =
1687            "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
1688        assert_eq!(
1689            format::format(pattern, &meta).unwrap(),
1690            "Radiohead/(1997) OK Computer/01. Airbag"
1691        );
1692    }
1693
1694    #[test]
1695    fn ancillary_file_detection() {
1696        let tmp = TempDir::new().unwrap();
1697        let dir = tmp.path();
1698        std::fs::write(dir.join("cover.jpg"), b"img").unwrap();
1699        std::fs::write(dir.join("cover.png"), b"img").unwrap();
1700        std::fs::write(dir.join("album.cue"), b"cue").unwrap();
1701        std::fs::write(dir.join("rip.log"), b"log").unwrap();
1702        std::fs::write(dir.join("track.flac"), b"audio").unwrap();
1703
1704        let found = find_ancillary_files(dir);
1705        assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1706        assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1707        assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1708        assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1709        assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1710    }
1711
1712    // ---- Preview / execute ----
1713
1714    #[test]
1715    fn preview_does_not_move_files() {
1716        let db = test_db();
1717        let tmp = TempDir::new().unwrap();
1718        let source = tmp.path().join("src/test.flac");
1719        add_track(&db, &source, "Airbag", 1);
1720
1721        let result = preview(
1722            &db,
1723            "%album artist%/%album%/%title%",
1724            Some(tmp.path()),
1725            true,
1726        )
1727        .unwrap();
1728        assert!(source.exists());
1729        assert_eq!(result.moved_count(), 1);
1730    }
1731
1732    #[test]
1733    fn execute_moves_files_and_undo_reverts() {
1734        let db = test_db();
1735        let tmp = TempDir::new().unwrap();
1736        let source = tmp.path().join("src/test.flac");
1737        let id = add_track(&db, &source, "Airbag", 1);
1738
1739        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1740        assert_eq!(result.moved_count(), 1);
1741        assert_eq!(result.failures().count(), 0);
1742        assert!(!source.exists());
1743        let dest = result.moves().next().unwrap().dest().to_path_buf();
1744        assert!(dest.exists());
1745        assert_eq!(db_path_of(&db, id).as_deref(), Some(dest.to_str().unwrap()));
1746
1747        let undone = undo(&db).unwrap();
1748        assert_eq!(undone.restored, 1);
1749        assert!(undone.errors.is_empty());
1750        assert!(source.exists());
1751        assert!(!dest.exists());
1752        assert_eq!(
1753            db_path_of(&db, id).as_deref(),
1754            Some(source.to_str().unwrap())
1755        );
1756    }
1757
1758    /// The preview a user confirms and the moves that follow must agree. They read
1759    /// metadata through the same resolver, so a pattern using an album-level field
1760    /// (here `%label%`) resolves identically in both.
1761    #[test]
1762    fn preview_and_execute_agree_on_destinations() {
1763        let db = test_db();
1764        let tmp = TempDir::new().unwrap();
1765        let pattern = "$if2(%label%,%album artist%)/%album%/[$num(%tracknumber%,2). ]%title%";
1766
1767        let source = tmp.path().join("src/aphex.flac");
1768        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1769        std::fs::write(&source, b"audio").unwrap();
1770        let mut meta = sample_meta("Xtal", "Aphex Twin", "Selected Ambient Works");
1771        meta.label = Some("Warp Records".into());
1772        meta.path = Some(source.to_string_lossy().into_owned());
1773        queries::upsert_track(&db.conn, &meta).unwrap();
1774
1775        let previewed = preview(&db, pattern, Some(tmp.path()), true).unwrap();
1776        assert_eq!(previewed.moved_count(), 1);
1777        let expected = previewed.moves().next().unwrap().dest().to_path_buf();
1778        assert!(expected.starts_with(tmp.path().join("Warp Records")));
1779
1780        let executed = execute(&db, pattern, Some(tmp.path())).unwrap();
1781        assert_eq!(executed.moved_count(), 1);
1782        assert_eq!(executed.moves().next().unwrap().dest(), expected);
1783        assert!(expected.exists());
1784    }
1785
1786    /// The whole macOS flow, end to end: files land from outside the library,
1787    /// get rows where they lie, and organize is what puts them under the music
1788    /// tree. Nothing about the import knows where they will end up.
1789    #[test]
1790    fn imported_files_organize_into_the_library_folder() {
1791        let db = test_db();
1792        let tmp = TempDir::new().unwrap();
1793        let outside = tmp.path().join("Downloads/rip");
1794        let library = tmp.path().join("Music");
1795        std::fs::create_dir_all(&outside).unwrap();
1796        std::fs::create_dir_all(&library).unwrap();
1797
1798        let dropped = outside.join("track.wav");
1799        crate::test_utils::generate_wav(&dropped, 44100, 1, 0.2, 16);
1800
1801        let imported = crate::index::scanner::import_paths(&db, std::slice::from_ref(&outside));
1802        assert_eq!(imported.track_ids.len(), 1, "errors: {:?}", imported.errors);
1803
1804        let result = execute_for_tracks(
1805            &db,
1806            &imported.track_ids,
1807            "%album artist%/%album%/%title%",
1808            Some(&library),
1809        )
1810        .unwrap();
1811
1812        assert_eq!(result.moved_count(), 1);
1813        let dest = result.moves().next().unwrap().dest();
1814        assert!(dest.starts_with(&library), "landed at {}", dest.display());
1815        assert!(dest.exists());
1816        assert!(
1817            !dropped.exists(),
1818            "the original should have moved, not copied"
1819        );
1820
1821        // The row followed the file, so playing it afterwards still works.
1822        assert_eq!(
1823            db_path_of(&db, imported.track_ids[0]).as_deref(),
1824            dest.to_str()
1825        );
1826    }
1827
1828    /// Generation is pure. It is what reruns on every keystroke, so if it ever
1829    /// starts touching the filesystem this is what says so: the destination is
1830    /// occupied and the source directory is full of cover art, and neither
1831    /// shows up until the disk is actually asked.
1832    #[test]
1833    fn generate_touches_no_files() {
1834        let db = test_db();
1835        let tmp = TempDir::new().unwrap();
1836        let source = tmp.path().join("src/track.flac");
1837        add_track(&db, &source, "Airbag", 1);
1838        std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
1839
1840        // Something is already sitting where the pattern points.
1841        let occupied = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
1842        std::fs::create_dir_all(occupied.parent().unwrap()).unwrap();
1843        std::fs::write(&occupied, b"the good rip").unwrap();
1844
1845        let selection = resolve(&db, None).unwrap();
1846        let mut result = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
1847
1848        // Pure pass: a move, no conflict, no ancillary — it has not looked.
1849        assert_eq!(result.moved_count(), 1);
1850        assert_eq!(result.conflicts().count(), 0);
1851        assert!(result.entries[0].ancillary.is_empty());
1852
1853        // Asking the disk is what finds both.
1854        check_against_disk(&mut result, true);
1855        assert_eq!(result.moved_count(), 0);
1856        assert_eq!(result.conflicts().count(), 1);
1857        assert_eq!(result.conflicts().next().unwrap().dest(), occupied);
1858    }
1859
1860    /// A caller with no library folder configured passes an empty base dir.
1861    /// Taken literally it means "relative to wherever this process happens to
1862    /// be", which plans and previews cleanly and then fails on every file, so
1863    /// it is refused before a plan exists to confirm.
1864    #[test]
1865    fn an_empty_base_dir_is_not_a_destination() {
1866        let db = test_db();
1867        add_track(&db, Path::new("/tmp/src/a.flac"), "Airbag", 1);
1868
1869        let err = preview(&db, "%title%", Some(Path::new("")), false).unwrap_err();
1870        assert!(matches!(err, OrganizeError::NoDestination));
1871
1872        let err = execute(&db, "%title%", Some(Path::new(""))).unwrap_err();
1873        assert!(matches!(err, OrganizeError::NoDestination));
1874    }
1875
1876    /// Resolving once and generating many times must agree with planning from
1877    /// scratch, or the preview would be lying about what execute will do.
1878    #[test]
1879    fn generate_agrees_with_a_full_plan() {
1880        let db = test_db();
1881        let tmp = TempDir::new().unwrap();
1882        add_track(&db, &tmp.path().join("src/a.flac"), "Airbag", 1);
1883        add_track(&db, &tmp.path().join("src/b.flac"), "Karma Police", 2);
1884        let pattern = "%album artist%/%album%/%tracknumber%. %title%";
1885
1886        let selection = resolve(&db, None).unwrap();
1887        let mut generated = generate(&selection, pattern, tmp.path());
1888        check_against_disk(&mut generated, true);
1889        let planned = preview(&db, pattern, Some(tmp.path()), true).unwrap();
1890
1891        assert_eq!(generated.entries.len(), planned.entries.len());
1892        for (a, b) in generated.entries.iter().zip(&planned.entries) {
1893            assert_eq!(a.from, b.from);
1894            assert_eq!(a.to, b.to);
1895            assert_eq!(a.outcome, b.outcome);
1896            assert_eq!(a.ancillary, b.ancillary);
1897        }
1898    }
1899
1900    /// One readdir per source directory, not one per file — the thing that made
1901    /// a preview over an album on a slow volume cost what it did.
1902    #[test]
1903    fn ancillary_is_scanned_once_per_directory() {
1904        let db = test_db();
1905        let tmp = TempDir::new().unwrap();
1906        for (i, title) in ["Airbag", "Karma Police", "Lucky"].iter().enumerate() {
1907            add_track(
1908                &db,
1909                &tmp.path().join(format!("src/{i}.flac")),
1910                title,
1911                i as i32 + 1,
1912            );
1913        }
1914        std::fs::write(tmp.path().join("src/cover.jpg"), b"art").unwrap();
1915
1916        let result = preview(
1917            &db,
1918            "%album artist%/%album%/%title%",
1919            Some(tmp.path()),
1920            true,
1921        )
1922        .unwrap();
1923
1924        // The cover travels with exactly one of them, not all three.
1925        let carrying: Vec<_> = result.moves().filter(|e| !e.ancillary.is_empty()).collect();
1926        assert_eq!(carrying.len(), 1);
1927        assert_eq!(carrying[0].ancillary.len(), 1);
1928    }
1929
1930    /// The disk pass must not mistake a file for its own obstacle. Nothing
1931    /// stops a caller planning against paths a previous run already moved, and
1932    /// a bare `exists()` on the destination says "occupied" for every one of
1933    /// them.
1934    #[test]
1935    fn a_file_already_at_its_destination_is_unchanged_not_a_conflict() {
1936        let db = test_db();
1937        let tmp = TempDir::new().unwrap();
1938        let pattern = "%album artist%/%album%/%title%";
1939        add_track(&db, &tmp.path().join("src/a.flac"), "Airbag", 1);
1940
1941        let moved = execute(&db, pattern, Some(tmp.path())).unwrap();
1942        assert_eq!(moved.moved_count(), 1);
1943
1944        // Plan again, from the rows as they now stand.
1945        let again = preview(&db, pattern, Some(tmp.path()), true).unwrap();
1946        assert_eq!(again.conflicts().count(), 0);
1947        assert_eq!(again.unchanged_count(), 1);
1948    }
1949
1950    #[test]
1951    fn ancillary_files_stay_put_when_they_are_turned_off() {
1952        let db = test_db();
1953        let tmp = TempDir::new().unwrap();
1954        let source = tmp.path().join("src/a.flac");
1955        add_track(&db, &source, "Airbag", 1);
1956        std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
1957
1958        let selection = resolve(&db, None).unwrap();
1959        let mut off = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
1960        check_against_disk(&mut off, false);
1961        assert!(off.moves().all(|e| e.ancillary.is_empty()));
1962
1963        let mut on = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
1964        check_against_disk(&mut on, true);
1965        assert_eq!(on.moves().next().unwrap().ancillary.len(), 1);
1966    }
1967
1968    // ---- Collisions ----
1969
1970    #[test]
1971    fn colliding_destinations_leave_both_files_intact() {
1972        let db = test_db();
1973        let tmp = TempDir::new().unwrap();
1974        let first = tmp.path().join("src/a.flac");
1975        let second = tmp.path().join("src/b.flac");
1976        // Same title, different track numbers: two library rows, one destination.
1977        let first_id = add_track(&db, &first, "Airbag", 1);
1978        let second_id = add_track(&db, &second, "Airbag", 2);
1979        let second_bytes = std::fs::read(&second).unwrap();
1980
1981        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1982
1983        assert_eq!(result.moved_count(), 1);
1984
1985        // The loser is a row in the plan, flagged as a conflict and still
1986        // carrying the destination it lost — that is what a preview shows.
1987        let blocked = result.conflicts().next().unwrap();
1988        assert_eq!(result.conflicts().count(), 1);
1989        assert_eq!(blocked.from, second);
1990        assert_eq!(blocked.dest(), result.moves().next().unwrap().dest());
1991
1992        // The loser stays exactly where it was, byte for byte.
1993        assert!(second.exists());
1994        assert_eq!(std::fs::read(&second).unwrap(), second_bytes);
1995        assert_eq!(
1996            db_path_of(&db, second_id).as_deref(),
1997            Some(second.to_str().unwrap())
1998        );
1999
2000        let dest = result.moves().next().unwrap().dest();
2001        assert_eq!(
2002            std::fs::read(dest).unwrap(),
2003            b"audio bytes for Airbag".to_vec()
2004        );
2005        assert_eq!(
2006            db_path_of(&db, first_id).as_deref(),
2007            Some(dest.to_str().unwrap())
2008        );
2009    }
2010
2011    #[test]
2012    fn existing_destination_is_never_overwritten() {
2013        let db = test_db();
2014        let tmp = TempDir::new().unwrap();
2015        let source = tmp.path().join("src/new.flac");
2016        add_track(&db, &source, "Airbag", 1);
2017
2018        // Something unrelated is already sitting at the destination.
2019        let dest = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
2020        std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
2021        std::fs::write(&dest, b"the good rip").unwrap();
2022
2023        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2024        assert_eq!(result.moved_count(), 0);
2025
2026        // Flagged as a conflict against the occupied path, so a preview can say
2027        // what would have been overwritten before anyone presses the button.
2028        let blocked = result.conflicts().next().unwrap();
2029        assert_eq!(result.conflicts().count(), 1);
2030        assert_eq!(blocked.from, source);
2031        assert_eq!(blocked.dest(), dest);
2032        assert!(blocked.outcome.reason().unwrap().contains("overwritten"));
2033
2034        assert_eq!(std::fs::read(&dest).unwrap(), b"the good rip".to_vec());
2035        assert!(source.exists());
2036    }
2037
2038    /// `move_file` is the last line of defence: even handed a destination that exists,
2039    /// it refuses rather than replacing it.
2040    #[test]
2041    fn move_file_refuses_an_occupied_destination() {
2042        let tmp = TempDir::new().unwrap();
2043        let from = tmp.path().join("a.flac");
2044        let to = tmp.path().join("b.flac");
2045        std::fs::write(&from, b"source").unwrap();
2046        std::fs::write(&to, b"keep me").unwrap();
2047
2048        let err = move_file(&from, &to).unwrap_err();
2049        assert!(matches!(err, OrganizeError::DestinationExists(_)));
2050        assert_eq!(std::fs::read(&to).unwrap(), b"keep me".to_vec());
2051        assert_eq!(std::fs::read(&from).unwrap(), b"source".to_vec());
2052    }
2053
2054    #[cfg(any(target_os = "macos", target_os = "ios"))]
2055    #[test]
2056    fn case_only_difference_collides_on_a_case_insensitive_filesystem() {
2057        let db = test_db();
2058        let tmp = TempDir::new().unwrap();
2059        let first = tmp.path().join("src/1.flac");
2060        let second = tmp.path().join("src/2.flac");
2061        std::fs::create_dir_all(first.parent().unwrap()).unwrap();
2062        for (path, title, number) in [(&first, "Rain", 1i32), (&second, "RAIN", 2)] {
2063            std::fs::write(path, format!("audio bytes for {title}")).unwrap();
2064            let mut meta = sample_meta(title, "Radiohead", "OK Computer");
2065            meta.track_number = Some(number);
2066            meta.path = Some(path.to_string_lossy().into_owned());
2067            queries::upsert_track(&db.conn, &meta).unwrap();
2068        }
2069
2070        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2071        assert_eq!(result.moved_count(), 1);
2072        assert_eq!(result.failures().count(), 1);
2073        assert!(second.exists());
2074        assert_eq!(
2075            std::fs::read(&second).unwrap(),
2076            b"audio bytes for RAIN".to_vec()
2077        );
2078    }
2079
2080    /// A rename that only changes case has to go via a temporary name: reserving the
2081    /// destination would otherwise open the source file itself.
2082    /// Not on iOS: the simulator's sandboxed filesystem answers `stat` for
2083    /// `Rain.flac` with ENOENT while `open(O_CREAT|O_EXCL)` on the same name
2084    /// answers EEXIST — measured, both, in one directory. `paths_equal` reads
2085    /// `stat`, so the case-only rename it exists to catch cannot be detected
2086    /// there. Unverified on a device, where the volume is ordinary APFS.
2087    #[cfg(not(target_os = "ios"))]
2088    #[test]
2089    fn case_only_rename_keeps_the_file() {
2090        let tmp = TempDir::new().unwrap();
2091        let from = tmp.path().join("rain.flac");
2092        let to = tmp.path().join("Rain.flac");
2093        std::fs::write(&from, b"audio bytes").unwrap();
2094
2095        move_file(&from, &to).unwrap();
2096
2097        assert_eq!(std::fs::read(&to).unwrap(), b"audio bytes".to_vec());
2098        let names: Vec<String> = std::fs::read_dir(tmp.path())
2099            .unwrap()
2100            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
2101            .collect();
2102        assert_eq!(names, vec!["Rain.flac".to_string()]);
2103    }
2104
2105    /// The cross-device path copies, flushes and verifies before unlinking the
2106    /// original, so an interrupted move can never leave a truncated file and no source.
2107    #[test]
2108    fn cross_device_copy_verifies_before_dropping_the_source() {
2109        let tmp = TempDir::new().unwrap();
2110        let from = tmp.path().join("a.flac");
2111        let to = tmp.path().join("b.flac");
2112        let bytes: Vec<u8> = (0..64_000u32).map(|i| (i % 251) as u8).collect();
2113        std::fs::write(&from, &bytes).unwrap();
2114        let mtime = std::fs::metadata(&from).unwrap().modified().unwrap();
2115
2116        copy_across_devices(&from, &to).unwrap();
2117
2118        assert!(!from.exists());
2119        assert_eq!(std::fs::read(&to).unwrap(), bytes);
2120        // Preserved, so scan_cache entries stay valid across a cross-device move.
2121        assert_eq!(std::fs::metadata(&to).unwrap().modified().unwrap(), mtime);
2122        // No temporary left behind.
2123        let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
2124            .unwrap()
2125            .filter(|e| {
2126                e.as_ref()
2127                    .unwrap()
2128                    .file_name()
2129                    .to_string_lossy()
2130                    .starts_with(".koan-")
2131            })
2132            .collect();
2133        assert!(leftovers.is_empty());
2134    }
2135
2136    #[test]
2137    fn free_space_check_ignores_same_device_moves() {
2138        let tmp = TempDir::new().unwrap();
2139        let from = tmp.path().join("a.flac");
2140        std::fs::write(&from, b"audio").unwrap();
2141        let moves = vec![FileMove {
2142            track_id: None,
2143            from,
2144            to: tmp.path().join("b.flac"),
2145            ancillary: Vec::new(),
2146        }];
2147        // A rename within one filesystem consumes no space.
2148        assert!(check_free_space(&moves, tmp.path()).is_ok());
2149    }
2150
2151    // ---- Bad patterns ----
2152
2153    #[test]
2154    fn unknown_function_refuses_the_move() {
2155        let db = test_db();
2156        let tmp = TempDir::new().unwrap();
2157        let source = tmp.path().join("src/test.flac");
2158        add_track(&db, &source, "Airbag", 1);
2159
2160        // $nun instead of $num.
2161        let result = execute(
2162            &db,
2163            "%album artist%/%album%/$nun(%tracknumber%,2). %title%",
2164            Some(tmp.path()),
2165        )
2166        .unwrap();
2167        assert_eq!(result.moved_count(), 0);
2168        assert_eq!(result.failures().count(), 1);
2169        assert!(result.failure_messages()[0].contains("unknown function"));
2170        assert!(source.exists());
2171    }
2172
2173    /// An empty last component used to append the extension to the parent directory,
2174    /// pointing every track on an album at one file.
2175    #[test]
2176    fn empty_final_component_refuses_the_move() {
2177        let db = test_db();
2178        let tmp = TempDir::new().unwrap();
2179        let first = tmp.path().join("src/a.flac");
2180        let second = tmp.path().join("src/b.flac");
2181        add_track(&db, &first, "Airbag", 1);
2182        add_track(&db, &second, "Karma Police", 2);
2183
2184        // The conditional resolves to nothing, leaving a trailing separator.
2185        let result = execute(
2186            &db,
2187            "%album artist%/%album%/[%nonexistent field%]",
2188            Some(tmp.path()),
2189        )
2190        .unwrap();
2191
2192        assert_eq!(result.moved_count(), 0);
2193        assert_eq!(result.failures().count(), 2);
2194        assert!(first.exists());
2195        assert!(second.exists());
2196        assert!(!tmp.path().join("Radiohead/OK Computer.flac").exists());
2197    }
2198
2199    #[test]
2200    fn long_title_is_truncated_rather_than_failing() {
2201        let db = test_db();
2202        let tmp = TempDir::new().unwrap();
2203        let source = tmp.path().join("src/test.flac");
2204        let title = "a".repeat(300);
2205        add_track(&db, &source, &title, 1);
2206
2207        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2208        assert_eq!(
2209            result.moved_count(),
2210            1,
2211            "errors: {:?}",
2212            result.failure_messages()
2213        );
2214        let name = result
2215            .moves()
2216            .next()
2217            .unwrap()
2218            .dest()
2219            .file_name()
2220            .unwrap()
2221            .to_string_lossy();
2222        assert!(name.len() <= MAX_FILE_NAME_BYTES);
2223        assert!(name.ends_with(".flac"));
2224        assert!(result.moves().next().unwrap().dest().exists());
2225    }
2226
2227    // ---- Directory cleanup ----
2228
2229    #[test]
2230    fn remove_empty_dirs_never_climbs_past_a_floor() {
2231        let tmp = TempDir::new().unwrap();
2232        let root = tmp.path().join("library");
2233        let nested = root.join("artist/album");
2234        std::fs::create_dir_all(&nested).unwrap();
2235
2236        remove_empty_dirs(&nested, std::slice::from_ref(&root));
2237
2238        assert!(!nested.exists());
2239        assert!(!root.join("artist").exists());
2240        assert!(root.exists(), "the library root must survive");
2241    }
2242
2243    #[test]
2244    fn remove_empty_dirs_stays_put_outside_any_floor() {
2245        let tmp = TempDir::new().unwrap();
2246        let outside = tmp.path().join("incoming/rip");
2247        std::fs::create_dir_all(&outside).unwrap();
2248
2249        remove_empty_dirs(&outside, &[tmp.path().join("library")]);
2250
2251        assert!(!outside.exists());
2252        assert!(
2253            tmp.path().join("incoming").exists(),
2254            "no floor means no climbing"
2255        );
2256    }
2257
2258    #[test]
2259    fn remove_empty_dirs_never_removes_a_floor_itself() {
2260        let tmp = TempDir::new().unwrap();
2261        let root = tmp.path().join("library");
2262        std::fs::create_dir_all(&root).unwrap();
2263
2264        remove_empty_dirs(&root, std::slice::from_ref(&root));
2265
2266        assert!(root.exists());
2267    }
2268
2269    // ---- Undo ----
2270
2271    #[test]
2272    fn undo_refuses_when_the_original_path_is_occupied() {
2273        let db = test_db();
2274        let tmp = TempDir::new().unwrap();
2275        let source = tmp.path().join("src/test.flac");
2276        add_track(&db, &source, "Airbag", 1);
2277
2278        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2279        let dest = result.moves().next().unwrap().dest().to_path_buf();
2280
2281        // A different rip lands at the vacated path before the undo.
2282        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2283        std::fs::write(&source, b"a completely different rip").unwrap();
2284
2285        let undone = undo(&db).unwrap();
2286        assert_eq!(undone.restored, 0);
2287        assert_eq!(undone.errors.len(), 1);
2288        assert_eq!(
2289            std::fs::read(&source).unwrap(),
2290            b"a completely different rip".to_vec()
2291        );
2292        assert!(dest.exists());
2293        // The entry stays in the log so it can be undone once the path is free.
2294        assert_eq!(log_rows(&db).len(), 1);
2295    }
2296
2297    #[test]
2298    fn undo_refuses_when_the_moved_file_has_been_replaced() {
2299        let db = test_db();
2300        let tmp = TempDir::new().unwrap();
2301        let source = tmp.path().join("src/test.flac");
2302        add_track(&db, &source, "Airbag", 1);
2303
2304        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2305        let dest = result.moves().next().unwrap().dest().to_path_buf();
2306        std::fs::write(&dest, b"replaced with something else entirely").unwrap();
2307
2308        let undone = undo(&db).unwrap();
2309        assert_eq!(undone.restored, 0);
2310        assert_eq!(undone.errors.len(), 1);
2311        assert!(!source.exists());
2312        assert!(dest.exists());
2313    }
2314
2315    /// `created_at` has one-second resolution, so batches are ordered by primary key.
2316    #[test]
2317    fn undo_takes_the_newest_batch_when_timestamps_tie() {
2318        let db = test_db();
2319        let tmp = TempDir::new().unwrap();
2320        let older = tmp.path().join("older.flac");
2321        let newer = tmp.path().join("newer.flac");
2322        std::fs::write(&older, b"older").unwrap();
2323        std::fs::write(&newer, b"newer").unwrap();
2324        let moved_older = tmp.path().join("moved-older.flac");
2325        let moved_newer = tmp.path().join("moved-newer.flac");
2326        std::fs::rename(&older, &moved_older).unwrap();
2327        std::fs::rename(&newer, &moved_newer).unwrap();
2328
2329        for (batch, from, to) in [
2330            ("batch-1", &older, &moved_older),
2331            ("batch-2", &newer, &moved_newer),
2332        ] {
2333            db.conn
2334                .execute(
2335                    "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, created_at)
2336                     VALUES (?1, NULL, ?2, ?3, '2025-01-01 00:00:00')",
2337                    params![
2338                        batch,
2339                        from.to_string_lossy().as_ref(),
2340                        to.to_string_lossy().as_ref()
2341                    ],
2342                )
2343                .unwrap();
2344        }
2345
2346        let undone = undo(&db).unwrap();
2347        assert_eq!(undone.restored, 1);
2348        assert!(newer.exists(), "the newest batch is the one undone");
2349        assert!(!older.exists());
2350    }
2351
2352    // ---- Database consistency ----
2353
2354    #[test]
2355    fn favourites_and_queue_state_follow_the_move() {
2356        let db = test_db();
2357        let tmp = TempDir::new().unwrap();
2358        let source = tmp.path().join("src/test.flac");
2359        add_track(&db, &source, "Airbag", 1);
2360        let source_str = source.to_string_lossy().into_owned();
2361
2362        let track = queries::track_id_by_path(&db.conn, &source_str)
2363            .unwrap()
2364            .unwrap();
2365        queries::add_favourite(&db.conn, crate::db::queries::LOCAL_USER, track).unwrap();
2366        let item = PersistedQueueItem {
2367            path: source_str.clone(),
2368            title: "Airbag".into(),
2369            artist: "Radiohead".into(),
2370            album_artist: "Radiohead".into(),
2371            album: "OK Computer".into(),
2372            year: None,
2373            codec: None,
2374            track_number: Some(1),
2375            disc: Some(1),
2376            duration_ms: None,
2377            db_id: None,
2378            pre_shuffle: None,
2379        };
2380        queries::save_playback_state(
2381            &db.conn,
2382            &[item],
2383            Default::default(),
2384            Some(&source_str),
2385            0,
2386            false,
2387        )
2388        .unwrap();
2389
2390        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2391        let dest = result.moves().next().unwrap().dest().to_path_buf();
2392        let dest_str = dest.to_string_lossy().into_owned();
2393
2394        let favourites =
2395            queries::load_favourites(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2396        assert!(favourites.contains(&track));
2397        assert_eq!(
2398            queries::track_id_by_path(&db.conn, &dest_str).unwrap(),
2399            Some(track)
2400        );
2401
2402        let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
2403        assert_eq!(state.items[0].path, dest_str);
2404        assert_eq!(state.cursor_path.as_deref(), Some(dest_str.as_str()));
2405
2406        assert_eq!(undo(&db).unwrap().restored, 1);
2407
2408        let favourites =
2409            queries::load_favourites(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2410        assert!(favourites.contains(&track));
2411        let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
2412        assert_eq!(state.items[0].path, source_str);
2413        assert_eq!(state.cursor_path.as_deref(), Some(source_str.as_str()));
2414    }
2415
2416    #[test]
2417    fn scan_cache_follows_the_move() {
2418        let db = test_db();
2419        let tmp = TempDir::new().unwrap();
2420        let source = tmp.path().join("src/test.flac");
2421        let id = add_track(&db, &source, "Airbag", 1);
2422        db.conn
2423            .execute(
2424                "INSERT INTO scan_cache (path, mtime, size, track_id) VALUES (?1, 1, 1, ?2)",
2425                params![source.to_string_lossy().as_ref(), id],
2426            )
2427            .unwrap();
2428
2429        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2430        let dest = result
2431            .moves()
2432            .next()
2433            .unwrap()
2434            .dest()
2435            .to_string_lossy()
2436            .into_owned();
2437
2438        let cached: String = db
2439            .conn
2440            .query_row(
2441                "SELECT path FROM scan_cache WHERE track_id = ?1",
2442                params![id],
2443                |r| r.get(0),
2444            )
2445            .unwrap();
2446        assert_eq!(cached, dest);
2447    }
2448
2449    /// A failure partway through a batch must leave the rest of the run truthful: the
2450    /// files that moved are in the result and the log, the one that didn't is in neither.
2451    #[test]
2452    fn partial_failure_leaves_the_database_and_result_consistent() {
2453        let db = test_db();
2454        let tmp = TempDir::new().unwrap();
2455        let first = tmp.path().join("src/a.flac");
2456        let clash = tmp.path().join("src/b.flac");
2457        let third = tmp.path().join("src/c.flac");
2458        let first_id = add_track(&db, &first, "Airbag", 1);
2459        let clash_id = add_track(&db, &clash, "Airbag", 2);
2460        let third_id = add_track(&db, &third, "Karma Police", 3);
2461
2462        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2463
2464        assert_eq!(result.moved_count(), 2);
2465        assert_eq!(result.failures().count(), 1);
2466
2467        let logged = log_rows(&db);
2468        assert_eq!(logged.len(), 2);
2469        for file_move in result.moves() {
2470            assert!(file_move.dest().exists());
2471            assert!(
2472                logged
2473                    .iter()
2474                    .any(|(_, _, to)| Path::new(to) == file_move.dest())
2475            );
2476        }
2477
2478        // The failed file is untouched, in the filesystem and in the database.
2479        assert!(clash.exists());
2480        assert_eq!(
2481            db_path_of(&db, clash_id).as_deref(),
2482            Some(clash.to_str().unwrap())
2483        );
2484        assert_ne!(db_path_of(&db, first_id).as_deref(), first.to_str());
2485        assert_ne!(db_path_of(&db, third_id).as_deref(), third.to_str());
2486    }
2487
2488    /// The TUI organizes a selection of paths. Files the library doesn't know about
2489    /// still get a log entry, so the whole run can be undone.
2490    #[test]
2491    fn unknown_paths_are_logged_and_undoable() {
2492        let db = test_db();
2493        let tmp = TempDir::new().unwrap();
2494        let known = tmp.path().join("src/known.flac");
2495        add_track(&db, &known, "Airbag", 1);
2496
2497        let result = run(
2498            &db,
2499            Selection::Paths(std::slice::from_ref(&known)),
2500            "%album artist%/%album%/%title%",
2501            tmp.path(),
2502        )
2503        .unwrap();
2504
2505        assert_eq!(result.moved_count(), 1);
2506        let logged = log_rows(&db);
2507        assert_eq!(logged.len(), 1);
2508        assert!(logged[0].0.is_some());
2509
2510        assert_eq!(undo(&db).unwrap().restored, 1);
2511        assert!(known.exists());
2512    }
2513
2514    #[test]
2515    fn ancillary_files_move_with_the_album() {
2516        let db = test_db();
2517        let tmp = TempDir::new().unwrap();
2518        let source = tmp.path().join("src/test.flac");
2519        add_track(&db, &source, "Airbag", 1);
2520        std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2521
2522        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2523        assert_eq!(result.moved_count(), 1);
2524        let dest_dir = result.moves().next().unwrap().dest().parent().unwrap();
2525        assert!(dest_dir.join("cover.jpg").exists());
2526
2527        // Both the audio and the artwork are in the log, so undo restores both.
2528        assert_eq!(log_rows(&db).len(), 2);
2529        assert_eq!(undo(&db).unwrap().restored, 2);
2530        assert!(source.parent().unwrap().join("cover.jpg").exists());
2531    }
2532
2533    // ---- Extension handling ----
2534
2535    #[test]
2536    fn extension_not_clobbered_by_dots_in_title() {
2537        // Regression: with_extension() replaces after the LAST dot,
2538        // destroying titles with dots ("0111. Bicep - TANGZ II" → "0111.flac").
2539        let db = test_db();
2540        let tmp = TempDir::new().unwrap();
2541        let source = tmp.path().join("src/CHROMA 011 A.L.O.E II.flac");
2542        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2543        std::fs::write(&source, b"fake").unwrap();
2544
2545        let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
2546        meta.track_number = Some(10);
2547        meta.date = Some("2025-11-21".into());
2548        meta.path = Some(source.to_string_lossy().into_owned());
2549        queries::upsert_track(&db.conn, &meta).unwrap();
2550
2551        let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2552        let result = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2553        assert_eq!(result.moved_count(), 1);
2554        assert_eq!(
2555            result
2556                .moves()
2557                .next()
2558                .unwrap()
2559                .dest()
2560                .file_name()
2561                .unwrap()
2562                .to_string_lossy(),
2563            "0110. Bicep - CHROMA 011 A.L.O.E II.flac"
2564        );
2565    }
2566
2567    #[test]
2568    fn extension_preserved_for_tracknumber_dot() {
2569        // "0111. Bicep - TANGZ II" must not become "0111.flac"
2570        let db = test_db();
2571        let tmp = TempDir::new().unwrap();
2572        let source = tmp.path().join("src/CHROMA 012 TANGZ II.flac");
2573        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2574        std::fs::write(&source, b"fake").unwrap();
2575
2576        let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
2577        meta.track_number = Some(11);
2578        meta.date = Some("2025-11-21".into());
2579        meta.path = Some(source.to_string_lossy().into_owned());
2580        queries::upsert_track(&db.conn, &meta).unwrap();
2581
2582        let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2583        let result = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2584        assert_eq!(result.moved_count(), 1);
2585        assert_eq!(
2586            result
2587                .moves()
2588                .next()
2589                .unwrap()
2590                .dest()
2591                .file_name()
2592                .unwrap()
2593                .to_string_lossy(),
2594            "0111. Bicep - CHROMA 012 TANGZ II.flac"
2595        );
2596    }
2597}