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
13const 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
26const 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#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum PlanOutcome {
64 Move,
66 Unchanged,
68 Conflict(String),
72 Error(String),
74}
75
76impl PlanOutcome {
77 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#[derive(Debug, Clone)]
89pub struct PlanEntry {
90 pub track_id: Option<i64>,
93 pub from: PathBuf,
94 pub to: Option<PathBuf>,
97 pub ancillary: Vec<(PathBuf, PathBuf)>,
98 pub outcome: PlanOutcome,
99}
100
101impl PlanEntry {
102 #[cfg(test)]
105 fn dest(&self) -> &Path {
106 self.to.as_deref().expect("plan entry has no destination")
107 }
108
109 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#[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 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 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 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#[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
190type 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
200enum Selection<'a> {
202 All,
203 TrackIds(&'a [i64]),
204 Paths(&'a [PathBuf]),
205}
206
207#[derive(Default, Clone)]
209struct AlbumFacts {
210 date: Option<String>,
211 label: Option<String>,
212}
213
214struct ResolvedTrack {
216 source: PathBuf,
217 track_id: Option<i64>,
218 metadata: Result<TrackMetadata, String>,
219}
220
221struct 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 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 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
297fn 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 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
325fn 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
346fn 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 if ANCILLARY_PATTERNS.iter().any(|p| name == *p) {
366 files.push(path);
367 continue;
368 }
369 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#[derive(Default)]
385struct DestinationLedger {
386 taken: HashSet<String>,
387}
388
389impl DestinationLedger {
390 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 = "tvos",
398 target_os = "windows"
399 )) {
400 key.to_lowercase()
401 } else {
402 key
403 }
404 }
405
406 fn claim(&mut self, path: &Path) -> bool {
408 self.taken.insert(Self::key(path))
409 }
410}
411
412fn plan_single_move(
418 source: &Path,
419 track_id: Option<i64>,
420 metadata: &TrackMetadata,
421 pattern: &str,
422 base_dir: &Path,
423 dests: &mut DestinationLedger,
424) -> PlanEntry {
425 let entry = |to: Option<PathBuf>, outcome: PlanOutcome| PlanEntry {
426 track_id,
427 from: source.to_path_buf(),
428 to,
429 ancillary: Vec::new(),
430 outcome,
431 };
432 macro_rules! bail {
434 ($reason:expr) => {
435 return entry(None, PlanOutcome::Error($reason))
436 };
437 }
438
439 let relative = match format::format(pattern, metadata) {
440 Ok(r) => r,
441 Err(e) => bail!(format!("format error: {e}")),
442 };
443
444 if relative.is_empty() {
445 bail!("format string produced empty path".to_string());
446 }
447
448 let sanitized = match sanitize_relative_path(&relative) {
449 Ok(p) => p,
450 Err(e) => bail!(e),
451 };
452
453 let ext = source
457 .extension()
458 .and_then(|e| e.to_str())
459 .unwrap_or("flac");
460 let Some(stem) = sanitized.file_name().and_then(|n| n.to_str()) else {
461 bail!("format string produced an unusable file name".to_string());
462 };
463 let stem = truncate_bytes(stem, MAX_FILE_NAME_BYTES.saturating_sub(ext.len() + 1)).trim_end();
466 if stem.is_empty() {
467 bail!("format string produced an empty file name".to_string());
468 }
469 let mut dest = base_dir.to_path_buf();
470 if let Some(parent) = sanitized.parent() {
471 dest.push(parent);
472 }
473 dest.push(format!("{stem}.{ext}"));
474
475 if !dest.starts_with(base_dir) {
477 let reason = format!(
478 "path traversal blocked: destination {} escapes base dir {}",
479 dest.display(),
480 base_dir.display()
481 );
482 return entry(Some(dest), PlanOutcome::Error(reason));
483 }
484
485 if source == dest {
486 dests.claim(&dest);
488 return entry(Some(dest), PlanOutcome::Unchanged);
489 }
490
491 if !dests.claim(&dest) {
492 return entry(
493 Some(dest),
494 PlanOutcome::Conflict("another file in this run is already going here".into()),
495 );
496 }
497
498 PlanEntry {
502 track_id,
503 from: source.to_path_buf(),
504 to: Some(dest),
505 ancillary: Vec::new(),
506 outcome: PlanOutcome::Move,
507 }
508}
509
510pub fn check_against_disk(result: &mut OrganizeResult, move_ancillary: bool) {
518 let mut dests = DestinationLedger::default();
519 let mut planned_ancillary: HashSet<PathBuf> = HashSet::new();
520 let mut ancillary_by_dir: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
523
524 for entry in &mut result.entries {
525 let (PlanOutcome::Move, Some(dest)) = (&entry.outcome, entry.to.clone()) else {
526 continue;
527 };
528
529 if dest.exists() && !paths_equal(&entry.from, &dest) {
533 entry.outcome =
534 PlanOutcome::Conflict("a file is already here — it would be overwritten".into());
535 continue;
536 }
537 dests.claim(&dest);
538
539 if !move_ancillary {
540 continue;
541 }
542 let source_dir = entry.from.parent().unwrap_or(Path::new("."));
543 let dest_dir = dest.parent().unwrap_or(Path::new("."));
544 if source_dir == dest_dir {
545 continue;
546 }
547 let candidates = ancillary_by_dir
548 .entry(source_dir.to_path_buf())
549 .or_insert_with(|| find_ancillary_files(source_dir))
550 .clone();
551 for anc_path in candidates {
552 if planned_ancillary.contains(&anc_path) {
553 continue;
554 }
555 let Some(anc_name) = anc_path.file_name() else {
556 continue;
557 };
558 let anc_dest = dest_dir.join(anc_name);
559 if anc_dest.exists() || !dests.claim(&anc_dest) {
562 continue;
563 }
564 planned_ancillary.insert(anc_path.clone());
565 entry.ancillary.push((anc_path, anc_dest));
566 }
567 }
568}
569
570fn resolve_from_rows(rows: Vec<TrackRow>, albums: &HashMap<i64, AlbumFacts>) -> Vec<ResolvedTrack> {
571 let fallback = AlbumFacts::default();
572 rows.into_iter()
573 .filter_map(|track| {
574 let source = PathBuf::from(track.path.as_ref()?);
575 if !source.exists() {
576 return None; }
578 let facts = track
579 .album_id
580 .and_then(|id| albums.get(&id))
581 .unwrap_or(&fallback);
582 Some(ResolvedTrack {
583 source,
584 track_id: Some(track.id),
585 metadata: Ok(TrackMetadata::from_track_row(&track, facts)),
586 })
587 })
588 .collect()
589}
590
591fn read_tag_metadata(source: &Path) -> Result<TrackMetadata, String> {
592 if !source.exists() {
593 return Err("file not found".to_string());
594 }
595 crate::index::metadata::read_metadata(source)
596 .map(|m| TrackMetadata::from_file_meta(&m))
597 .map_err(|e| format!("metadata error: {e}"))
598}
599
600fn resolve_from_paths(
603 db: &Database,
604 paths: &[PathBuf],
605 albums: &HashMap<i64, AlbumFacts>,
606) -> Result<Vec<ResolvedTrack>, OrganizeError> {
607 use rayon::prelude::*;
608
609 let mut spelling = crate::index::spelling::Spelling::default();
611 let paths: Vec<PathBuf> = paths.iter().map(|p| spelling.on_disk(p)).collect();
612 let path_strings: Vec<String> = paths
613 .iter()
614 .map(|p| p.to_string_lossy().into_owned())
615 .collect();
616 let known = queries::tracks_by_paths(&db.conn, &path_strings)?;
617
618 let mut tagged: HashMap<PathBuf, Result<TrackMetadata, String>> = paths
620 .par_iter()
621 .filter(|p| !known.contains_key(p.to_string_lossy().as_ref()))
622 .map(|p| (p.clone(), read_tag_metadata(p)))
623 .collect();
624
625 let fallback = AlbumFacts::default();
626 let mut resolved = Vec::with_capacity(paths.len());
627 for (path, path_str) in paths.iter().zip(&path_strings) {
628 let entry = match known.get(path_str) {
629 Some(track) => {
630 let facts = track
631 .album_id
632 .and_then(|id| albums.get(&id))
633 .unwrap_or(&fallback);
634 ResolvedTrack {
635 source: path.clone(),
636 track_id: Some(track.id),
637 metadata: Ok(TrackMetadata::from_track_row(track, facts)),
638 }
639 }
640 None => ResolvedTrack {
641 source: path.clone(),
642 track_id: None,
643 metadata: tagged
644 .remove(path)
645 .unwrap_or_else(|| Err("duplicate path in selection".to_string())),
646 },
647 };
648 resolved.push(entry);
649 }
650 Ok(resolved)
651}
652
653pub struct ResolvedSelection {
660 tracks: Vec<ResolvedTrack>,
661}
662
663impl ResolvedSelection {
664 pub fn len(&self) -> usize {
667 self.tracks.len()
668 }
669
670 pub fn is_empty(&self) -> bool {
671 self.tracks.is_empty()
672 }
673}
674
675pub fn resolve(
680 db: &Database,
681 track_ids: Option<&[i64]>,
682) -> Result<ResolvedSelection, OrganizeError> {
683 let selection = match track_ids {
684 Some(ids) => Selection::TrackIds(ids),
685 None => Selection::All,
686 };
687 resolve_selection(db, selection)
688}
689
690fn resolve_selection(
691 db: &Database,
692 selection: Selection<'_>,
693) -> Result<ResolvedSelection, OrganizeError> {
694 let albums = load_album_facts(&db.conn)?;
695 let tracks = match selection {
696 Selection::All => resolve_from_rows(queries::all_tracks(&db.conn)?, &albums),
697 Selection::TrackIds(ids) => {
698 let mut rows = Vec::with_capacity(ids.len());
699 for &id in ids {
700 if let Some(row) = queries::get_track_row(&db.conn, id)? {
701 rows.push(row);
702 }
703 }
704 resolve_from_rows(rows, &albums)
705 }
706 Selection::Paths(paths) => resolve_from_paths(db, paths, &albums)?,
707 };
708 Ok(ResolvedSelection { tracks })
709}
710
711pub fn generate(selection: &ResolvedSelection, pattern: &str, base_dir: &Path) -> OrganizeResult {
718 let mut entries = Vec::with_capacity(selection.tracks.len());
719 let mut dests = DestinationLedger::default();
720
721 for track in &selection.tracks {
722 let metadata = match &track.metadata {
723 Ok(m) => m,
724 Err(msg) => {
725 entries.push(PlanEntry {
726 track_id: track.track_id,
727 from: track.source.clone(),
728 to: None,
729 ancillary: Vec::new(),
730 outcome: PlanOutcome::Error(msg.clone()),
731 });
732 continue;
733 }
734 };
735 entries.push(plan_single_move(
736 &track.source,
737 track.track_id,
738 metadata,
739 pattern,
740 base_dir,
741 &mut dests,
742 ));
743 }
744
745 OrganizeResult { entries }
746}
747
748fn plan(
752 db: &Database,
753 selection: Selection<'_>,
754 pattern: &str,
755 base_dir: &Path,
756 check_disk: bool,
757) -> Result<OrganizeResult, OrganizeError> {
758 let resolved = resolve_selection(db, selection)?;
759 let mut result = generate(&resolved, pattern, base_dir);
760 if check_disk {
761 check_against_disk(&mut result, move_ancillary());
762 }
763 Ok(result)
764}
765
766fn run(
769 db: &Database,
770 selection: Selection<'_>,
771 pattern: &str,
772 base_dir: &Path,
773) -> Result<OrganizeResult, OrganizeError> {
774 let mut result = plan(db, selection, pattern, base_dir, true)?;
775
776 let pending: Vec<FileMove> = result
777 .entries
778 .iter()
779 .filter_map(PlanEntry::as_move)
780 .collect();
781 if pending.is_empty() {
782 return Ok(result);
783 }
784
785 check_free_space(&pending, base_dir)?;
786
787 let batch_id = batch_id();
788 let floors = cleanup_floors(Some(base_dir));
789
790 for file_move in pending {
794 let failure = match execute_single_move(db, &file_move, &batch_id, &floors) {
795 Ok(()) => verify_move(&file_move).err(),
796 Err(e) => Some(e.to_string()),
797 };
798 let Some(reason) = failure else { continue };
799 log::warn!(
800 "organize: {} → {} failed: {reason}",
801 file_move.from.display(),
802 file_move.to.display()
803 );
804 if let Some(entry) = result.entries.iter_mut().find(|e| e.from == file_move.from) {
805 entry.outcome = PlanOutcome::Error(reason);
806 }
807 }
808
809 let moved = db
810 .conn
811 .prepare("SELECT from_path, to_path FROM organize_log WHERE batch_id = ?1")
812 .and_then(|mut stmt| {
813 stmt.query_map(params![batch_id], |r| {
814 Ok((
815 PathBuf::from(r.get::<_, String>(0)?),
816 PathBuf::from(r.get::<_, String>(1)?),
817 ))
818 })?
819 .collect::<Result<Vec<_>, _>>()
820 });
821 match moved {
822 Ok(moved) => follow_playlist_files(db, &moved),
823 Err(e) => log::warn!("organize: could not read back what moved: {e}"),
824 }
825
826 Ok(result)
827}
828
829pub fn preview(
837 db: &Database,
838 pattern: &str,
839 base_dir: Option<&Path>,
840 check_disk: bool,
841) -> Result<OrganizeResult, OrganizeError> {
842 let base = resolve_base_dir(base_dir)?;
843 plan(db, Selection::All, pattern, &base, check_disk)
844}
845
846pub fn execute(
848 db: &Database,
849 pattern: &str,
850 base_dir: Option<&Path>,
851) -> Result<OrganizeResult, OrganizeError> {
852 let base = resolve_base_dir(base_dir)?;
853 run(db, Selection::All, pattern, &base)
854}
855
856pub fn preview_for_tracks(
858 db: &Database,
859 track_ids: &[i64],
860 pattern: &str,
861 base_dir: Option<&Path>,
862 check_disk: bool,
863) -> Result<OrganizeResult, OrganizeError> {
864 let base = resolve_base_dir(base_dir)?;
865 plan(
866 db,
867 Selection::TrackIds(track_ids),
868 pattern,
869 &base,
870 check_disk,
871 )
872}
873
874pub fn execute_for_tracks(
876 db: &Database,
877 track_ids: &[i64],
878 pattern: &str,
879 base_dir: Option<&Path>,
880) -> Result<OrganizeResult, OrganizeError> {
881 let base = resolve_base_dir(base_dir)?;
882 run(db, Selection::TrackIds(track_ids), pattern, &base)
883}
884
885pub fn preview_for_paths(
887 paths: &[PathBuf],
888 pattern: &str,
889 base_dir: Option<&Path>,
890 check_disk: bool,
891) -> Result<OrganizeResult, OrganizeError> {
892 let db = crate::db::pool::shared().get()?;
893 let base = resolve_base_dir(base_dir)?;
894 plan(&db, Selection::Paths(paths), pattern, &base, check_disk)
895}
896
897pub fn execute_for_paths(
900 paths: &[PathBuf],
901 pattern: &str,
902 base_dir: Option<&Path>,
903) -> Result<OrganizeResult, OrganizeError> {
904 let db = crate::db::pool::shared().get()?;
905 let base = resolve_base_dir(base_dir)?;
906 run(&db, Selection::Paths(paths), pattern, &base)
907}
908
909fn verify_move(file_move: &FileMove) -> Result<(), String> {
911 if !file_move.to.exists() {
912 return Err(format!(
913 "destination not found after move: {}",
914 file_move.to.display()
915 ));
916 }
917 if file_move.from.exists() && !paths_equal(&file_move.from, &file_move.to) {
918 return Err(format!(
919 "source still exists after move: {}",
920 file_move.from.display()
921 ));
922 }
923 Ok(())
924}
925
926fn log_move(
927 conn: &Connection,
928 batch_id: &str,
929 track_id: Option<i64>,
930 from: &Path,
931 to: &Path,
932 size: Option<u64>,
933 mtime: Option<i64>,
934) -> Result<(), OrganizeError> {
935 conn.execute(
936 "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, size_bytes, mtime)
937 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
938 params![
939 batch_id,
940 track_id,
941 from.to_string_lossy().as_ref(),
942 to.to_string_lossy().as_ref(),
943 size.map(|s| s as i64),
944 mtime,
945 ],
946 )?;
947 Ok(())
948}
949
950fn rewrite_path_references(conn: &Connection, old: &Path, new: &Path) -> Result<(), OrganizeError> {
956 let old_lossy = old.to_string_lossy();
957 let new_lossy = new.to_string_lossy();
958 let old_path = old_lossy.as_ref();
959 let new_path = new_lossy.as_ref();
960
961 conn.execute(
962 "UPDATE tracks SET path = ?1 WHERE path = ?2",
963 params![new_path, old_path],
964 )?;
965 crate::db::queries::sources::rename_file(conn, old_path, new_path)?;
966 conn.execute(
967 "UPDATE tracks SET cached_path = ?1 WHERE cached_path = ?2",
968 params![new_path, old_path],
969 )?;
970 conn.execute(
971 "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
972 params![new_path, old_path],
973 )?;
974 conn.execute(
975 "UPDATE playback_position SET cursor_id = ?1 WHERE cursor_id = ?2",
976 params![new_path, old_path],
977 )?;
978 conn.execute(
981 "UPDATE playlists SET source_path = ?1 WHERE source_path = ?2",
982 params![new_path, old_path],
983 )?;
984 rewrite_queue_json(conn, old_path, new_path)?;
985 Ok(())
986}
987
988fn follow_playlist_files(db: &Database, moved: &[(PathBuf, PathBuf)]) {
998 use crate::index::playlist_files::{follow_moves, is_m3u};
999 if moved.is_empty() {
1000 return;
1001 }
1002 let new_place: HashMap<PathBuf, PathBuf> = moved.iter().cloned().collect();
1003 let came_from: HashMap<&PathBuf, &PathBuf> = moved.iter().map(|(f, t)| (t, f)).collect();
1004 let mut lists: Vec<PathBuf> = match db
1005 .conn
1006 .prepare("SELECT source_path FROM playlists WHERE source_path IS NOT NULL")
1007 .and_then(|mut stmt| {
1008 stmt.query_map([], |r| r.get::<_, String>(0))?
1009 .collect::<Result<Vec<_>, _>>()
1010 }) {
1011 Ok(sources) => sources.into_iter().map(PathBuf::from).collect(),
1012 Err(e) => {
1013 log::warn!("organize: could not list playlist files to update: {e}");
1014 Vec::new()
1015 }
1016 };
1017 lists.extend(moved.iter().map(|(_, to)| to.clone()));
1018 lists.retain(|list| is_m3u(list) && list.is_file());
1019 lists.sort();
1020 lists.dedup();
1021
1022 for list in lists {
1023 let was_at = came_from.get(&list).map_or(list.as_path(), |p| p.as_path());
1024 match follow_moves(&list, was_at, &new_place) {
1025 Ok(true) => {
1026 let meta = std::fs::metadata(&list).ok();
1027 if let Err(e) = db.conn.execute(
1028 "UPDATE organize_log SET size_bytes = ?2, mtime = ?3 WHERE to_path = ?1",
1029 params![
1030 list.to_string_lossy().as_ref(),
1031 meta.as_ref().map(|m| m.len() as i64),
1032 meta.as_ref().and_then(mtime_secs),
1033 ],
1034 ) {
1035 log::warn!("organize: undo may not recognise {}: {e}", list.display());
1036 }
1037 }
1038 Ok(false) => {}
1039 Err(e) => log::warn!(
1040 "organize: {} still names files at their old paths: {e}",
1041 list.display()
1042 ),
1043 }
1044 }
1045}
1046
1047fn rewrite_queue_json(
1049 conn: &Connection,
1050 old_path: &str,
1051 new_path: &str,
1052) -> Result<(), OrganizeError> {
1053 let mut stmt =
1054 conn.prepare("SELECT id, queue_json FROM playback_state WHERE instr(queue_json, ?1) > 0")?;
1055 let rows: Vec<(i64, String)> = stmt
1056 .query_map(params![old_path], |row| Ok((row.get(0)?, row.get(1)?)))?
1057 .collect::<Result<Vec<_>, _>>()?;
1058 drop(stmt);
1059
1060 for (id, json) in rows {
1061 let Ok(mut items) = serde_json::from_str::<Vec<PersistedQueueItem>>(&json) else {
1062 continue;
1063 };
1064 let mut changed = false;
1065 for item in &mut items {
1066 if item.path == old_path {
1067 item.path = new_path.to_string();
1068 changed = true;
1069 }
1070 }
1071 if !changed {
1072 continue;
1073 }
1074 let Ok(updated) = serde_json::to_string(&items) else {
1075 continue;
1076 };
1077 conn.execute(
1078 "UPDATE playback_state SET queue_json = ?1 WHERE id = ?2",
1079 params![updated, id],
1080 )?;
1081 }
1082 Ok(())
1083}
1084
1085fn execute_single_move(
1089 db: &Database,
1090 file_move: &FileMove,
1091 batch_id: &str,
1092 floors: &[PathBuf],
1093) -> Result<(), OrganizeError> {
1094 if let Some(parent) = file_move.to.parent() {
1095 std::fs::create_dir_all(parent)?;
1096 }
1097 let mut spelling = crate::index::spelling::Spelling::default();
1101 let to = &spelling.on_disk(&file_move.to);
1102
1103 let source_meta = std::fs::metadata(&file_move.from)?;
1104 let size = source_meta.len();
1105 let mtime = mtime_secs(&source_meta);
1106
1107 let tx = crate::db::queries::write_transaction(&db.conn)?;
1108 log_move(
1109 &tx,
1110 batch_id,
1111 file_move.track_id,
1112 &file_move.from,
1113 to,
1114 Some(size),
1115 mtime,
1116 )?;
1117 rewrite_path_references(&tx, &file_move.from, to)?;
1118
1119 move_file(&file_move.from, to)?;
1121
1122 let mut moved_ancillary: Vec<(&PathBuf, PathBuf)> = Vec::new();
1123 let mut failure = None;
1124 for (anc_from, anc_to) in &file_move.ancillary {
1125 let anc_to = spelling.on_disk(anc_to);
1126 if let Some(parent) = anc_to.parent()
1127 && std::fs::create_dir_all(parent).is_err()
1128 {
1129 continue;
1130 }
1131 match move_file(anc_from, &anc_to) {
1133 Ok(()) => {
1134 let meta = std::fs::metadata(&anc_to).ok();
1135 let logged = log_move(
1136 &tx,
1137 batch_id,
1138 None,
1139 anc_from,
1140 &anc_to,
1141 meta.as_ref().map(|m| m.len()),
1142 meta.as_ref().and_then(mtime_secs),
1143 )
1144 .and_then(|()| rewrite_path_references(&tx, anc_from, &anc_to));
1145 moved_ancillary.push((anc_from, anc_to));
1146 if let Err(e) = logged {
1147 failure = Some(e);
1148 break;
1149 }
1150 }
1151 Err(e) => log::warn!(
1152 "failed to move ancillary file {}: {}",
1153 anc_from.display(),
1154 e
1155 ),
1156 }
1157 }
1158
1159 let outcome = match failure {
1160 Some(e) => Err(e),
1161 None => tx.commit().map_err(OrganizeError::from),
1162 };
1163
1164 if let Err(e) = outcome {
1165 for (anc_from, anc_to) in moved_ancillary {
1168 let _ = move_file(&anc_to, anc_from);
1169 }
1170 let _ = move_file(to, &file_move.from);
1171 return Err(e);
1172 }
1173
1174 if let Some(source_dir) = file_move.from.parent() {
1175 remove_empty_dirs(source_dir, floors);
1176 }
1177
1178 Ok(())
1179}
1180
1181pub fn undo(db: &Database) -> Result<UndoResult, OrganizeError> {
1187 undo_within(db, &cleanup_floors(None))
1188}
1189
1190fn undo_within(db: &Database, floors: &[PathBuf]) -> Result<UndoResult, OrganizeError> {
1192 let batch_id: String = db
1195 .conn
1196 .query_row(
1197 "SELECT batch_id FROM organize_log ORDER BY id DESC LIMIT 1",
1198 [],
1199 |row| row.get(0),
1200 )
1201 .map_err(|_| OrganizeError::NothingToUndo)?;
1202
1203 let mut stmt = db.conn.prepare(
1204 "SELECT id, from_path, to_path, size_bytes, mtime FROM organize_log
1205 WHERE batch_id = ?1 ORDER BY id DESC",
1206 )?;
1207
1208 let entries: Vec<UndoEntry> = stmt
1209 .query_map(params![batch_id], |row| {
1210 Ok((
1211 row.get(0)?,
1212 row.get(1)?,
1213 row.get(2)?,
1214 row.get(3)?,
1215 row.get(4)?,
1216 ))
1217 })?
1218 .collect::<Result<Vec<_>, _>>()?;
1219 drop(stmt);
1220
1221 let mut result = UndoResult::default();
1222 let mut restored = Vec::new();
1223
1224 for (log_id, from_path, to_path, size, mtime) in &entries {
1225 let to = Path::new(to_path);
1226 let from = Path::new(from_path);
1227
1228 if !matches!(to.try_exists(), Ok(true)) {
1229 if crate::index::known_missing(to) && destination_reachable(to, floors) {
1234 db.conn
1235 .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
1236 } else {
1237 result.errors.push((
1238 to.to_path_buf(),
1239 "cannot be reached; kept for an undo once it is back".into(),
1240 ));
1241 }
1242 continue;
1243 }
1244
1245 if from.exists() && !paths_equal(from, to) {
1246 result.errors.push((
1247 from.to_path_buf(),
1248 format!(
1249 "another file now occupies the original path; {} left in place",
1250 to.display()
1251 ),
1252 ));
1253 continue;
1254 }
1255
1256 if let Err(msg) = matches_logged_file(to, *size, *mtime) {
1257 result.errors.push((to.to_path_buf(), msg));
1258 continue;
1259 }
1260
1261 if let Some(parent) = from.parent()
1262 && let Err(e) = std::fs::create_dir_all(parent)
1263 {
1264 result.errors.push((from.to_path_buf(), e.to_string()));
1265 continue;
1266 }
1267
1268 let tx = crate::db::queries::write_transaction(&db.conn)?;
1269 if let Err(e) = rewrite_path_references(&tx, to, from) {
1270 result.errors.push((to.to_path_buf(), e.to_string()));
1271 continue;
1272 }
1273 if let Err(e) = move_file(to, from) {
1274 result.errors.push((to.to_path_buf(), e.to_string()));
1275 continue;
1276 }
1277 if let Err(e) = tx.execute("DELETE FROM organize_log WHERE id = ?1", params![log_id]) {
1278 let _ = move_file(from, to);
1279 result.errors.push((to.to_path_buf(), e.to_string()));
1280 continue;
1281 }
1282 if let Err(e) = tx.commit() {
1283 let _ = move_file(from, to);
1284 result.errors.push((to.to_path_buf(), e.to_string()));
1285 continue;
1286 }
1287
1288 if let Some(parent) = to.parent() {
1289 remove_empty_dirs(parent, floors);
1290 }
1291
1292 result.restored += 1;
1293 restored.push((to.to_path_buf(), from.to_path_buf()));
1294 }
1295 follow_playlist_files(db, &restored);
1296
1297 Ok(result)
1298}
1299
1300fn destination_reachable(to: &Path, floors: &[PathBuf]) -> bool {
1304 match floors
1305 .iter()
1306 .filter(|floor| to.starts_with(floor))
1307 .max_by_key(|floor| floor.as_os_str().len())
1308 {
1309 Some(floor) => std::fs::read_dir(floor).is_ok_and(|mut d| d.next().is_some()),
1310 None => matches!(volume_root(to).try_exists(), Ok(true)),
1311 }
1312}
1313
1314fn volume_root(path: &Path) -> PathBuf {
1318 let parts: Vec<_> = path.components().collect();
1319 let name = |i: usize| parts.get(i).and_then(|c| c.as_os_str().to_str());
1320 let depth = match (name(1), name(2)) {
1321 (Some("Volumes" | "mnt"), _) => 3,
1322 (Some("media"), _) => 4,
1323 (Some("run"), Some("media")) => 5,
1324 _ => 1,
1325 };
1326 parts.iter().take(depth).collect()
1327}
1328
1329fn matches_logged_file(path: &Path, size: Option<i64>, mtime: Option<i64>) -> Result<(), String> {
1332 let (Some(size), Some(mtime)) = (size, mtime) else {
1333 return Ok(());
1334 };
1335 let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
1336 if meta.len() != size as u64 {
1337 return Err(format!(
1338 "{} has changed since it was moved (size differs); left in place",
1339 path.display()
1340 ));
1341 }
1342 if mtime_secs(&meta).is_some_and(|current| current != mtime) {
1343 return Err(format!(
1344 "{} has changed since it was moved (modification time differs); left in place",
1345 path.display()
1346 ));
1347 }
1348 Ok(())
1349}
1350
1351fn mtime_secs(meta: &std::fs::Metadata) -> Option<i64> {
1352 meta.modified()
1353 .ok()?
1354 .duration_since(UNIX_EPOCH)
1355 .ok()
1356 .map(|d| d.as_secs() as i64)
1357}
1358
1359fn cleanup_floors(base: Option<&Path>) -> Vec<PathBuf> {
1361 let mut floors: Vec<PathBuf> = base.map(Path::to_path_buf).into_iter().collect();
1362 if let Ok(config) = crate::config::Config::load() {
1363 floors.extend(config.library.folders);
1364 }
1365 floors
1366}
1367
1368fn remove_empty_dirs(start: &Path, floors: &[PathBuf]) {
1371 let mut current = start.to_path_buf();
1372 loop {
1373 if floors.iter().any(|floor| floor == ¤t) {
1374 break;
1375 }
1376 let empty = std::fs::read_dir(¤t)
1377 .map(|mut d| d.next().is_none())
1378 .unwrap_or(false);
1379 if !empty || std::fs::remove_dir(¤t).is_err() {
1380 break;
1381 }
1382 let Some(parent) = current.parent() else {
1383 break;
1384 };
1385 if !floors
1387 .iter()
1388 .any(|floor| parent.starts_with(floor) && parent != floor.as_path())
1389 {
1390 break;
1391 }
1392 current = parent.to_path_buf();
1393 }
1394}
1395
1396fn move_file(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1398 if from == to {
1399 return Ok(());
1400 }
1401 if paths_equal(from, to) {
1402 return rename_via_temp(from, to);
1406 }
1407
1408 match std::fs::OpenOptions::new()
1411 .write(true)
1412 .create_new(true)
1413 .open(to)
1414 {
1415 Ok(_) => {}
1416 Err(e) if e.kind() == ErrorKind::AlreadyExists => {
1417 return Err(OrganizeError::DestinationExists(to.to_path_buf()));
1418 }
1419 Err(e) => return Err(e.into()),
1420 }
1421
1422 match transfer(from, to) {
1423 Ok(()) => Ok(()),
1424 Err(e) => {
1425 let _ = std::fs::remove_file(to);
1427 Err(e)
1428 }
1429 }
1430}
1431
1432fn rename_via_temp(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1433 let temp = temp_sibling(to);
1434 std::fs::rename(from, &temp)?;
1435 match std::fs::rename(&temp, to) {
1436 Ok(()) => Ok(()),
1437 Err(e) => {
1438 let _ = std::fs::rename(&temp, from);
1439 Err(e.into())
1440 }
1441 }
1442}
1443
1444fn transfer(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1446 match std::fs::rename(from, to) {
1447 Ok(()) => Ok(()),
1448 Err(e) if e.raw_os_error() == Some(18) => copy_across_devices(from, to),
1450 Err(e) => Err(e.into()),
1451 }
1452}
1453
1454fn copy_across_devices(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1457 let source_meta = std::fs::metadata(from)?;
1458 let expected = source_meta.len();
1459 let temp = temp_sibling(to);
1460
1461 let copied = {
1462 let mut reader = std::fs::File::open(from)?;
1463 let mut writer = std::fs::OpenOptions::new()
1464 .write(true)
1465 .create_new(true)
1466 .open(&temp)?;
1467 let copied = std::io::copy(&mut reader, &mut writer)?;
1468 writer.sync_all()?;
1470 if let Ok(modified) = source_meta.modified() {
1471 let _ = writer.set_modified(modified);
1472 }
1473 copied
1474 };
1475
1476 let written = std::fs::metadata(&temp).map(|m| m.len()).unwrap_or(0);
1477 if copied != expected || written != expected {
1478 let _ = std::fs::remove_file(&temp);
1479 return Err(OrganizeError::ShortCopy {
1480 path: from.to_path_buf(),
1481 expected,
1482 copied: copied.min(written),
1483 });
1484 }
1485
1486 if let Err(e) = std::fs::rename(&temp, to) {
1487 let _ = std::fs::remove_file(&temp);
1488 return Err(e.into());
1489 }
1490 std::fs::remove_file(from)?;
1491 Ok(())
1492}
1493
1494fn temp_sibling(path: &Path) -> PathBuf {
1495 let nanos = SystemTime::now()
1496 .duration_since(UNIX_EPOCH)
1497 .unwrap_or_default()
1498 .as_nanos();
1499 path.with_file_name(format!(".koan-{}-{}.tmp", std::process::id(), nanos))
1500}
1501
1502fn paths_equal(a: &Path, b: &Path) -> bool {
1505 if a == b {
1506 return true;
1507 }
1508 #[cfg(unix)]
1509 {
1510 use std::os::unix::fs::MetadataExt;
1511 if let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) {
1512 return ma.dev() == mb.dev() && ma.ino() == mb.ino();
1513 }
1514 }
1515 false
1516}
1517
1518fn check_free_space(moves: &[FileMove], base_dir: &Path) -> Result<(), OrganizeError> {
1521 let Some(target) = existing_ancestor(base_dir) else {
1522 return Ok(());
1523 };
1524 let Some(target_device) = device_id(&target) else {
1525 return Ok(());
1526 };
1527
1528 let mut needed = 0u64;
1529 for file_move in moves {
1530 if device_id(&file_move.from).is_some_and(|d| d == target_device) {
1531 continue;
1532 }
1533 if let Ok(meta) = std::fs::metadata(&file_move.from) {
1534 needed = needed.saturating_add(meta.len());
1535 }
1536 }
1537 if needed == 0 {
1538 return Ok(());
1539 }
1540
1541 match available_bytes(&target) {
1542 Some(available) if available < needed => {
1543 Err(OrganizeError::NotEnoughSpace { needed, available })
1544 }
1545 _ => Ok(()),
1546 }
1547}
1548
1549fn existing_ancestor(path: &Path) -> Option<PathBuf> {
1550 path.ancestors().find(|p| p.exists()).map(Path::to_path_buf)
1551}
1552
1553#[cfg(unix)]
1554fn device_id(path: &Path) -> Option<u64> {
1555 use std::os::unix::fs::MetadataExt;
1556 std::fs::metadata(path).ok().map(|m| m.dev())
1557}
1558
1559#[cfg(not(unix))]
1560fn device_id(_path: &Path) -> Option<u64> {
1561 None
1562}
1563
1564#[cfg(unix)]
1565fn available_bytes(path: &Path) -> Option<u64> {
1566 use std::os::unix::ffi::OsStrExt;
1567 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1568 let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
1569 if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
1570 return None;
1571 }
1572 (stat.f_bavail as u64).checked_mul(stat.f_frsize as u64)
1574}
1575
1576#[cfg(not(unix))]
1577fn available_bytes(_path: &Path) -> Option<u64> {
1578 None
1579}
1580
1581fn resolve_base_dir(base_dir: Option<&Path>) -> Result<PathBuf, OrganizeError> {
1589 let dir = match base_dir {
1590 Some(dir) => dir.to_path_buf(),
1591 None => crate::config::Config::load()
1592 .map_err(|e| OrganizeError::Io(std::io::Error::other(e.to_string())))?
1593 .library
1594 .folders
1595 .into_iter()
1596 .find(|folder| !folder.as_os_str().is_empty())
1597 .ok_or(OrganizeError::NoDestination)?,
1598 };
1599
1600 if dir.as_os_str().is_empty() {
1601 return Err(OrganizeError::NoDestination);
1602 }
1603 Ok(dir)
1604}
1605
1606fn move_ancillary() -> bool {
1609 crate::config::Config::load()
1610 .map(|c| c.organize.move_ancillary)
1611 .unwrap_or(true)
1612}
1613
1614fn batch_id() -> String {
1615 let now = SystemTime::now()
1616 .duration_since(UNIX_EPOCH)
1617 .unwrap_or_default();
1618 format!("batch-{}", now.as_nanos())
1619}
1620
1621#[cfg(test)]
1622mod tests {
1623 use super::*;
1624 use crate::db::queries::TrackMeta;
1625 use crate::db::schema;
1626 use tempfile::TempDir;
1627
1628 fn test_db() -> Database {
1629 let conn = rusqlite::Connection::open_in_memory().unwrap();
1630 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1631 schema::create_tables(&conn).unwrap();
1632 Database { conn }
1633 }
1634
1635 fn sample_meta(title: &str, artist: &str, album: &str) -> TrackMeta {
1636 TrackMeta {
1637 title: title.into(),
1638 artist: artist.into(),
1639 album_artist: Some(artist.into()),
1640 album: album.into(),
1641 date: Some("1997-06-16".into()),
1642 disc: Some(1),
1643 track_number: Some(1),
1644 genre: Some("Rock".into()),
1645 label: None,
1646 duration_ms: Some(240_000),
1647 codec: Some("FLAC".into()),
1648 sample_rate: Some(44100),
1649 bit_depth: Some(16),
1650 channels: Some(2),
1651 bitrate: Some(1000),
1652 size_bytes: Some(30_000_000),
1653 mtime: Some(1700000000),
1654 path: None,
1655 source: "local".into(),
1656 remote_id: None,
1657 remote_url: None,
1658 album_remote_id: None,
1659 artist_remote_id: None,
1660 mbid: None,
1661 album_mbid: None,
1662 album_added_at: None,
1663 credits: Default::default(),
1664 }
1665 }
1666
1667 fn sample_track_row(title: &str, artist: &str, album: &str) -> TrackRow {
1668 TrackRow {
1669 id: 1,
1670 album_id: Some(1),
1671 artist_id: Some(1),
1672 artist_name: artist.into(),
1673 album_artist_name: artist.into(),
1674 album_title: album.into(),
1675 disc: Some(1),
1676 track_number: Some(1),
1677 title: title.into(),
1678 duration_ms: Some(240_000),
1679 path: Some("/music/test.flac".into()),
1680 codec: Some("FLAC".into()),
1681 sample_rate: Some(44100),
1682 bit_depth: Some(16),
1683 channels: Some(2),
1684 bitrate: Some(1000),
1685 genre: None,
1686 source: "local".into(),
1687 remote_id: None,
1688 cached_path: None,
1689 }
1690 }
1691
1692 fn add_track(db: &Database, path: &Path, title: &str, track_number: i32) -> i64 {
1694 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1695 std::fs::write(path, format!("audio bytes for {title}")).unwrap();
1696 let mut meta = sample_meta(title, "Radiohead", "OK Computer");
1697 meta.track_number = Some(track_number);
1698 meta.path = Some(path.to_string_lossy().into_owned());
1699 queries::upsert_track(&db.conn, &meta).unwrap()
1700 }
1701
1702 fn db_path_of(db: &Database, track_id: i64) -> Option<String> {
1703 db.conn
1704 .query_row(
1705 "SELECT path FROM tracks WHERE id = ?1",
1706 params![track_id],
1707 |row| row.get(0),
1708 )
1709 .unwrap()
1710 }
1711
1712 fn log_rows(db: &Database) -> Vec<(Option<i64>, String, String)> {
1713 let mut stmt = db
1714 .conn
1715 .prepare("SELECT track_id, from_path, to_path FROM organize_log ORDER BY id")
1716 .unwrap();
1717 let rows = stmt
1718 .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1719 .unwrap();
1720 rows.map(|r| r.unwrap()).collect()
1721 }
1722
1723 #[test]
1726 fn track_metadata_provider_fields() {
1727 let mut track = sample_track_row("Subterranean Homesick Alien", "Radiohead", "OK Computer");
1728 track.track_number = Some(3);
1729 track.genre = Some("Alternative".into());
1730
1731 let album = AlbumFacts {
1732 date: Some("1997-06-16".into()),
1733 label: Some("Parlophone".into()),
1734 };
1735 let meta = TrackMetadata::from_track_row(&track, &album);
1736 assert_eq!(
1737 meta.get_field("title").as_deref(),
1738 Some("Subterranean Homesick Alien")
1739 );
1740 assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
1741 assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
1742 assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
1743 assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
1744 assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
1745 assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
1746 assert_eq!(meta.get_field("label").as_deref(), Some("Parlophone"));
1747 assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
1748 assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
1749 assert_eq!(meta.get_field("nonexistent"), None);
1750 }
1751
1752 #[test]
1755 fn both_metadata_sources_expose_the_same_fields() {
1756 let mut track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1757 track.genre = Some("Rock".into());
1758 let album = AlbumFacts {
1759 date: Some("1997-06-16".into()),
1760 label: Some("Parlophone".into()),
1761 };
1762 let from_db = TrackMetadata::from_track_row(&track, &album);
1763
1764 let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1765 meta.label = Some("Parlophone".into());
1766 let from_tags = TrackMetadata::from_file_meta(&meta);
1767
1768 let mut db_fields: Vec<&String> = from_db.fields.keys().collect();
1769 let mut tag_fields: Vec<&String> = from_tags.fields.keys().collect();
1770 db_fields.sort();
1771 tag_fields.sort();
1772 assert_eq!(db_fields, tag_fields);
1773 }
1774
1775 #[test]
1776 fn sanitize_replaces_illegal_chars() {
1777 assert_eq!(sanitise_filename("AC/DC"), "AC_DC");
1778 assert_eq!(sanitise_filename("What?"), "What_");
1779 assert_eq!(sanitise_filename("a:b*c"), "a_b_c");
1780 assert_eq!(sanitise_filename("normal"), "normal");
1781 }
1782
1783 #[test]
1784 fn sanitize_relative_path_splits() {
1785 assert_eq!(
1786 sanitize_relative_path("Artist/Album/Track").unwrap(),
1787 PathBuf::from("Artist/Album/Track")
1788 );
1789 assert_eq!(
1790 sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag").unwrap(),
1791 PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
1792 );
1793 }
1794
1795 #[test]
1796 fn sanitize_relative_path_refuses_traversal_and_gaps() {
1797 assert!(sanitize_relative_path("../../../../etc/passwd").is_err());
1800 assert!(sanitize_relative_path("Artist/../../../outside").is_err());
1801 assert!(sanitize_relative_path("./Artist/./Album").is_err());
1802 assert!(sanitize_relative_path("Radiohead/OK Computer/").is_err());
1803 assert!(sanitize_relative_path("Radiohead//Airbag").is_err());
1804 assert!(sanitize_relative_path(" /Airbag").is_err());
1805 }
1806
1807 #[test]
1808 fn acdc_artist_name_sanitized() {
1809 let track = sample_track_row("Highway to Hell", "AC/DC", "Highway to Hell");
1810 let meta = TrackMetadata::from_track_row(&track, &AlbumFacts::default());
1811 assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
1812 let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
1813 assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
1814 }
1815
1816 #[test]
1817 fn format_string_evaluation() {
1818 let track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1819 let album = AlbumFacts {
1820 date: Some("1997-06-16".into()),
1821 label: None,
1822 };
1823 let meta = TrackMetadata::from_track_row(&track, &album);
1824 let pattern =
1825 "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
1826 assert_eq!(
1827 format::format(pattern, &meta).unwrap(),
1828 "Radiohead/(1997) OK Computer/01. Airbag"
1829 );
1830 }
1831
1832 #[test]
1833 fn ancillary_file_detection() {
1834 let tmp = TempDir::new().unwrap();
1835 let dir = tmp.path();
1836 std::fs::write(dir.join("cover.jpg"), b"img").unwrap();
1837 std::fs::write(dir.join("cover.png"), b"img").unwrap();
1838 std::fs::write(dir.join("album.cue"), b"cue").unwrap();
1839 std::fs::write(dir.join("rip.log"), b"log").unwrap();
1840 std::fs::write(dir.join("track.flac"), b"audio").unwrap();
1841
1842 let found = find_ancillary_files(dir);
1843 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1844 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1845 assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1846 assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1847 assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1848 }
1849
1850 #[test]
1853 fn preview_does_not_move_files() {
1854 let db = test_db();
1855 let tmp = TempDir::new().unwrap();
1856 let source = tmp.path().join("src/test.flac");
1857 add_track(&db, &source, "Airbag", 1);
1858
1859 let result = preview(
1860 &db,
1861 "%album artist%/%album%/%title%",
1862 Some(tmp.path()),
1863 true,
1864 )
1865 .unwrap();
1866 assert!(source.exists());
1867 assert_eq!(result.moved_count(), 1);
1868 }
1869
1870 #[test]
1871 fn execute_moves_files_and_undo_reverts() {
1872 let db = test_db();
1873 let tmp = TempDir::new().unwrap();
1874 let source = tmp.path().join("src/test.flac");
1875 let id = add_track(&db, &source, "Airbag", 1);
1876
1877 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1878 assert_eq!(result.moved_count(), 1);
1879 assert_eq!(result.failures().count(), 0);
1880 assert!(!source.exists());
1881 let dest = result.moves().next().unwrap().dest().to_path_buf();
1882 assert!(dest.exists());
1883 assert_eq!(db_path_of(&db, id).as_deref(), Some(dest.to_str().unwrap()));
1884
1885 let undone = undo(&db).unwrap();
1886 assert_eq!(undone.restored, 1);
1887 assert!(undone.errors.is_empty());
1888 assert!(source.exists());
1889 assert!(!dest.exists());
1890 assert_eq!(
1891 db_path_of(&db, id).as_deref(),
1892 Some(source.to_str().unwrap())
1893 );
1894 }
1895
1896 #[test]
1900 fn preview_and_execute_agree_on_destinations() {
1901 let db = test_db();
1902 let tmp = TempDir::new().unwrap();
1903 let pattern = "$if2(%label%,%album artist%)/%album%/[$num(%tracknumber%,2). ]%title%";
1904
1905 let source = tmp.path().join("src/aphex.flac");
1906 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1907 std::fs::write(&source, b"audio").unwrap();
1908 let mut meta = sample_meta("Xtal", "Aphex Twin", "Selected Ambient Works");
1909 meta.label = Some("Warp Records".into());
1910 meta.path = Some(source.to_string_lossy().into_owned());
1911 queries::upsert_track(&db.conn, &meta).unwrap();
1912
1913 let previewed = preview(&db, pattern, Some(tmp.path()), true).unwrap();
1914 assert_eq!(previewed.moved_count(), 1);
1915 let expected = previewed.moves().next().unwrap().dest().to_path_buf();
1916 assert!(expected.starts_with(tmp.path().join("Warp Records")));
1917
1918 let executed = execute(&db, pattern, Some(tmp.path())).unwrap();
1919 assert_eq!(executed.moved_count(), 1);
1920 assert_eq!(executed.moves().next().unwrap().dest(), expected);
1921 assert!(expected.exists());
1922 }
1923
1924 #[test]
1928 fn imported_files_organize_into_the_library_folder() {
1929 let db = test_db();
1930 let tmp = TempDir::new().unwrap();
1931 let outside = tmp.path().join("Downloads/rip");
1932 let library = tmp.path().join("Music");
1933 std::fs::create_dir_all(&outside).unwrap();
1934 std::fs::create_dir_all(&library).unwrap();
1935
1936 let dropped = outside.join("track.wav");
1937 crate::test_utils::generate_wav(&dropped, 44100, 1, 0.2, 16);
1938
1939 let imported = crate::index::scanner::import_paths(&db, std::slice::from_ref(&outside));
1940 assert_eq!(imported.track_ids.len(), 1, "errors: {:?}", imported.errors);
1941
1942 let result = execute_for_tracks(
1943 &db,
1944 &imported.track_ids,
1945 "%album artist%/%album%/%title%",
1946 Some(&library),
1947 )
1948 .unwrap();
1949
1950 assert_eq!(result.moved_count(), 1);
1951 let dest = result.moves().next().unwrap().dest();
1952 assert!(dest.starts_with(&library), "landed at {}", dest.display());
1953 assert!(dest.exists());
1954 assert!(
1955 !dropped.exists(),
1956 "the original should have moved, not copied"
1957 );
1958
1959 assert_eq!(
1961 db_path_of(&db, imported.track_ids[0]).as_deref(),
1962 dest.to_str()
1963 );
1964 }
1965
1966 #[test]
1971 fn an_organized_file_is_stored_as_the_disk_spells_it() {
1972 use unicode_normalization::UnicodeNormalization;
1973 let db = test_db();
1974 let tmp = TempDir::new().unwrap();
1975 let library = tmp.path().join("Music");
1976 let artist_on_disk: String = "Björk".nfd().collect();
1977 std::fs::create_dir_all(library.join(&artist_on_disk)).unwrap();
1978
1979 let source = tmp.path().join("in/track.wav");
1980 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1981 crate::test_utils::generate_wav(&source, 44100, 1, 0.2, 16);
1982 let imported = crate::index::scanner::import_paths(&db, std::slice::from_ref(&source));
1983 assert_eq!(imported.track_ids.len(), 1, "errors: {:?}", imported.errors);
1984
1985 let result = execute_for_tracks(
1986 &db,
1987 &imported.track_ids,
1988 &format!("{}/%title%", "Björk".nfc().collect::<String>()),
1989 Some(&library),
1990 )
1991 .unwrap();
1992 assert_eq!(result.moved_count(), 1, "{:?}", result.failures().count());
1993
1994 crate::index::scanner::scan_folder(
1995 &db,
1996 &library,
1997 crate::index::scanner::ScanOptions::default(),
1998 None,
1999 );
2000 let paths: Vec<String> = db
2001 .conn
2002 .prepare("SELECT path FROM local_files")
2003 .unwrap()
2004 .query_map([], |r| r.get(0))
2005 .unwrap()
2006 .collect::<Result<_, _>>()
2007 .unwrap();
2008 assert_eq!(paths.len(), 1, "one file, one row: {paths:?}");
2009 assert_eq!(
2010 db_path_of(&db, imported.track_ids[0]).as_ref(),
2011 paths.first()
2012 );
2013 }
2014
2015 #[cfg(target_os = "macos")]
2018 #[test]
2019 fn an_organized_file_is_stored_in_the_folders_own_case() {
2020 let db = test_db();
2021 let tmp = TempDir::new().unwrap();
2022 let library = tmp.path().join("Music");
2023 std::fs::create_dir_all(library.join("The beatles")).unwrap();
2024 if !library.join("THE BEATLES").exists() {
2025 return;
2026 }
2027
2028 let source = tmp.path().join("in/track.wav");
2029 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2030 crate::test_utils::generate_wav(&source, 44100, 1, 0.2, 16);
2031 let imported = crate::index::scanner::import_paths(&db, std::slice::from_ref(&source));
2032 assert_eq!(imported.track_ids.len(), 1, "errors: {:?}", imported.errors);
2033
2034 let result = execute_for_tracks(
2035 &db,
2036 &imported.track_ids,
2037 "The Beatles/%title%",
2038 Some(&library),
2039 )
2040 .unwrap();
2041 assert_eq!(result.moved_count(), 1);
2042
2043 crate::index::scanner::scan_folder(
2044 &db,
2045 &library,
2046 crate::index::scanner::ScanOptions::default(),
2047 None,
2048 );
2049 let paths: Vec<String> = db
2050 .conn
2051 .prepare("SELECT path FROM local_files")
2052 .unwrap()
2053 .query_map([], |r| r.get(0))
2054 .unwrap()
2055 .collect::<Result<_, _>>()
2056 .unwrap();
2057 assert_eq!(paths.len(), 1, "one file, one row: {paths:?}");
2058 assert!(paths[0].contains("/The beatles/"), "{paths:?}");
2059 }
2060
2061 #[test]
2066 fn generate_touches_no_files() {
2067 let db = test_db();
2068 let tmp = TempDir::new().unwrap();
2069 let source = tmp.path().join("src/track.flac");
2070 add_track(&db, &source, "Airbag", 1);
2071 std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2072
2073 let occupied = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
2075 std::fs::create_dir_all(occupied.parent().unwrap()).unwrap();
2076 std::fs::write(&occupied, b"the good rip").unwrap();
2077
2078 let selection = resolve(&db, None).unwrap();
2079 let mut result = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
2080
2081 assert_eq!(result.moved_count(), 1);
2083 assert_eq!(result.conflicts().count(), 0);
2084 assert!(result.entries[0].ancillary.is_empty());
2085
2086 check_against_disk(&mut result, true);
2088 assert_eq!(result.moved_count(), 0);
2089 assert_eq!(result.conflicts().count(), 1);
2090 assert_eq!(result.conflicts().next().unwrap().dest(), occupied);
2091 }
2092
2093 #[test]
2098 fn an_empty_base_dir_is_not_a_destination() {
2099 let db = test_db();
2100 add_track(&db, Path::new("/tmp/src/a.flac"), "Airbag", 1);
2101
2102 let err = preview(&db, "%title%", Some(Path::new("")), false).unwrap_err();
2103 assert!(matches!(err, OrganizeError::NoDestination));
2104
2105 let err = execute(&db, "%title%", Some(Path::new(""))).unwrap_err();
2106 assert!(matches!(err, OrganizeError::NoDestination));
2107 }
2108
2109 #[test]
2112 fn generate_agrees_with_a_full_plan() {
2113 let db = test_db();
2114 let tmp = TempDir::new().unwrap();
2115 add_track(&db, &tmp.path().join("src/a.flac"), "Airbag", 1);
2116 add_track(&db, &tmp.path().join("src/b.flac"), "Karma Police", 2);
2117 let pattern = "%album artist%/%album%/%tracknumber%. %title%";
2118
2119 let selection = resolve(&db, None).unwrap();
2120 let mut generated = generate(&selection, pattern, tmp.path());
2121 check_against_disk(&mut generated, true);
2122 let planned = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2123
2124 assert_eq!(generated.entries.len(), planned.entries.len());
2125 for (a, b) in generated.entries.iter().zip(&planned.entries) {
2126 assert_eq!(a.from, b.from);
2127 assert_eq!(a.to, b.to);
2128 assert_eq!(a.outcome, b.outcome);
2129 assert_eq!(a.ancillary, b.ancillary);
2130 }
2131 }
2132
2133 #[test]
2136 fn ancillary_is_scanned_once_per_directory() {
2137 let db = test_db();
2138 let tmp = TempDir::new().unwrap();
2139 for (i, title) in ["Airbag", "Karma Police", "Lucky"].iter().enumerate() {
2140 add_track(
2141 &db,
2142 &tmp.path().join(format!("src/{i}.flac")),
2143 title,
2144 i as i32 + 1,
2145 );
2146 }
2147 std::fs::write(tmp.path().join("src/cover.jpg"), b"art").unwrap();
2148
2149 let result = preview(
2150 &db,
2151 "%album artist%/%album%/%title%",
2152 Some(tmp.path()),
2153 true,
2154 )
2155 .unwrap();
2156
2157 let carrying: Vec<_> = result.moves().filter(|e| !e.ancillary.is_empty()).collect();
2159 assert_eq!(carrying.len(), 1);
2160 assert_eq!(carrying[0].ancillary.len(), 1);
2161 }
2162
2163 #[test]
2168 fn a_file_already_at_its_destination_is_unchanged_not_a_conflict() {
2169 let db = test_db();
2170 let tmp = TempDir::new().unwrap();
2171 let pattern = "%album artist%/%album%/%title%";
2172 add_track(&db, &tmp.path().join("src/a.flac"), "Airbag", 1);
2173
2174 let moved = execute(&db, pattern, Some(tmp.path())).unwrap();
2175 assert_eq!(moved.moved_count(), 1);
2176
2177 let again = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2179 assert_eq!(again.conflicts().count(), 0);
2180 assert_eq!(again.unchanged_count(), 1);
2181 }
2182
2183 #[test]
2184 fn ancillary_files_stay_put_when_they_are_turned_off() {
2185 let db = test_db();
2186 let tmp = TempDir::new().unwrap();
2187 let source = tmp.path().join("src/a.flac");
2188 add_track(&db, &source, "Airbag", 1);
2189 std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2190
2191 let selection = resolve(&db, None).unwrap();
2192 let mut off = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
2193 check_against_disk(&mut off, false);
2194 assert!(off.moves().all(|e| e.ancillary.is_empty()));
2195
2196 let mut on = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
2197 check_against_disk(&mut on, true);
2198 assert_eq!(on.moves().next().unwrap().ancillary.len(), 1);
2199 }
2200
2201 #[test]
2204 fn colliding_destinations_leave_both_files_intact() {
2205 let db = test_db();
2206 let tmp = TempDir::new().unwrap();
2207 let first = tmp.path().join("src/a.flac");
2208 let second = tmp.path().join("src/b.flac");
2209 let first_id = add_track(&db, &first, "Airbag", 1);
2211 let second_id = add_track(&db, &second, "Airbag", 2);
2212 let second_bytes = std::fs::read(&second).unwrap();
2213
2214 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2215
2216 assert_eq!(result.moved_count(), 1);
2217
2218 let blocked = result.conflicts().next().unwrap();
2221 assert_eq!(result.conflicts().count(), 1);
2222 assert_eq!(blocked.from, second);
2223 assert_eq!(blocked.dest(), result.moves().next().unwrap().dest());
2224
2225 assert!(second.exists());
2227 assert_eq!(std::fs::read(&second).unwrap(), second_bytes);
2228 assert_eq!(
2229 db_path_of(&db, second_id).as_deref(),
2230 Some(second.to_str().unwrap())
2231 );
2232
2233 let dest = result.moves().next().unwrap().dest();
2234 assert_eq!(
2235 std::fs::read(dest).unwrap(),
2236 b"audio bytes for Airbag".to_vec()
2237 );
2238 assert_eq!(
2239 db_path_of(&db, first_id).as_deref(),
2240 Some(dest.to_str().unwrap())
2241 );
2242 }
2243
2244 #[test]
2245 fn existing_destination_is_never_overwritten() {
2246 let db = test_db();
2247 let tmp = TempDir::new().unwrap();
2248 let source = tmp.path().join("src/new.flac");
2249 add_track(&db, &source, "Airbag", 1);
2250
2251 let dest = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
2253 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
2254 std::fs::write(&dest, b"the good rip").unwrap();
2255
2256 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2257 assert_eq!(result.moved_count(), 0);
2258
2259 let blocked = result.conflicts().next().unwrap();
2262 assert_eq!(result.conflicts().count(), 1);
2263 assert_eq!(blocked.from, source);
2264 assert_eq!(blocked.dest(), dest);
2265 assert!(blocked.outcome.reason().unwrap().contains("overwritten"));
2266
2267 assert_eq!(std::fs::read(&dest).unwrap(), b"the good rip".to_vec());
2268 assert!(source.exists());
2269 }
2270
2271 #[test]
2274 fn move_file_refuses_an_occupied_destination() {
2275 let tmp = TempDir::new().unwrap();
2276 let from = tmp.path().join("a.flac");
2277 let to = tmp.path().join("b.flac");
2278 std::fs::write(&from, b"source").unwrap();
2279 std::fs::write(&to, b"keep me").unwrap();
2280
2281 let err = move_file(&from, &to).unwrap_err();
2282 assert!(matches!(err, OrganizeError::DestinationExists(_)));
2283 assert_eq!(std::fs::read(&to).unwrap(), b"keep me".to_vec());
2284 assert_eq!(std::fs::read(&from).unwrap(), b"source".to_vec());
2285 }
2286
2287 #[cfg(any(target_os = "macos", target_os = "ios"))]
2288 #[test]
2289 fn case_only_difference_collides_on_a_case_insensitive_filesystem() {
2290 let db = test_db();
2291 let tmp = TempDir::new().unwrap();
2292 let first = tmp.path().join("src/1.flac");
2293 let second = tmp.path().join("src/2.flac");
2294 std::fs::create_dir_all(first.parent().unwrap()).unwrap();
2295 for (path, title, number) in [(&first, "Rain", 1i32), (&second, "RAIN", 2)] {
2296 std::fs::write(path, format!("audio bytes for {title}")).unwrap();
2297 let mut meta = sample_meta(title, "Radiohead", "OK Computer");
2298 meta.track_number = Some(number);
2299 meta.path = Some(path.to_string_lossy().into_owned());
2300 queries::upsert_track(&db.conn, &meta).unwrap();
2301 }
2302
2303 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2304 assert_eq!(result.moved_count(), 1);
2305 assert_eq!(result.failures().count(), 1);
2306 assert!(second.exists());
2307 assert_eq!(
2308 std::fs::read(&second).unwrap(),
2309 b"audio bytes for RAIN".to_vec()
2310 );
2311 }
2312
2313 #[cfg(not(target_os = "ios"))]
2321 #[test]
2322 fn case_only_rename_keeps_the_file() {
2323 let tmp = TempDir::new().unwrap();
2324 let from = tmp.path().join("rain.flac");
2325 let to = tmp.path().join("Rain.flac");
2326 std::fs::write(&from, b"audio bytes").unwrap();
2327
2328 move_file(&from, &to).unwrap();
2329
2330 assert_eq!(std::fs::read(&to).unwrap(), b"audio bytes".to_vec());
2331 let names: Vec<String> = std::fs::read_dir(tmp.path())
2332 .unwrap()
2333 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
2334 .collect();
2335 assert_eq!(names, vec!["Rain.flac".to_string()]);
2336 }
2337
2338 #[test]
2341 fn cross_device_copy_verifies_before_dropping_the_source() {
2342 let tmp = TempDir::new().unwrap();
2343 let from = tmp.path().join("a.flac");
2344 let to = tmp.path().join("b.flac");
2345 let bytes: Vec<u8> = (0..64_000u32).map(|i| (i % 251) as u8).collect();
2346 std::fs::write(&from, &bytes).unwrap();
2347 let mtime = std::fs::metadata(&from).unwrap().modified().unwrap();
2348
2349 copy_across_devices(&from, &to).unwrap();
2350
2351 assert!(!from.exists());
2352 assert_eq!(std::fs::read(&to).unwrap(), bytes);
2353 assert_eq!(std::fs::metadata(&to).unwrap().modified().unwrap(), mtime);
2355 let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
2357 .unwrap()
2358 .filter(|e| {
2359 e.as_ref()
2360 .unwrap()
2361 .file_name()
2362 .to_string_lossy()
2363 .starts_with(".koan-")
2364 })
2365 .collect();
2366 assert!(leftovers.is_empty());
2367 }
2368
2369 #[test]
2370 fn free_space_check_ignores_same_device_moves() {
2371 let tmp = TempDir::new().unwrap();
2372 let from = tmp.path().join("a.flac");
2373 std::fs::write(&from, b"audio").unwrap();
2374 let moves = vec![FileMove {
2375 track_id: None,
2376 from,
2377 to: tmp.path().join("b.flac"),
2378 ancillary: Vec::new(),
2379 }];
2380 assert!(check_free_space(&moves, tmp.path()).is_ok());
2382 }
2383
2384 #[test]
2387 fn unknown_function_refuses_the_move() {
2388 let db = test_db();
2389 let tmp = TempDir::new().unwrap();
2390 let source = tmp.path().join("src/test.flac");
2391 add_track(&db, &source, "Airbag", 1);
2392
2393 let result = execute(
2395 &db,
2396 "%album artist%/%album%/$nun(%tracknumber%,2). %title%",
2397 Some(tmp.path()),
2398 )
2399 .unwrap();
2400 assert_eq!(result.moved_count(), 0);
2401 assert_eq!(result.failures().count(), 1);
2402 assert!(result.failure_messages()[0].contains("unknown function"));
2403 assert!(source.exists());
2404 }
2405
2406 #[test]
2409 fn empty_final_component_refuses_the_move() {
2410 let db = test_db();
2411 let tmp = TempDir::new().unwrap();
2412 let first = tmp.path().join("src/a.flac");
2413 let second = tmp.path().join("src/b.flac");
2414 add_track(&db, &first, "Airbag", 1);
2415 add_track(&db, &second, "Karma Police", 2);
2416
2417 let result = execute(
2419 &db,
2420 "%album artist%/%album%/[%nonexistent field%]",
2421 Some(tmp.path()),
2422 )
2423 .unwrap();
2424
2425 assert_eq!(result.moved_count(), 0);
2426 assert_eq!(result.failures().count(), 2);
2427 assert!(first.exists());
2428 assert!(second.exists());
2429 assert!(!tmp.path().join("Radiohead/OK Computer.flac").exists());
2430 }
2431
2432 #[test]
2433 fn long_title_is_truncated_rather_than_failing() {
2434 let db = test_db();
2435 let tmp = TempDir::new().unwrap();
2436 let source = tmp.path().join("src/test.flac");
2437 let title = "a".repeat(300);
2438 add_track(&db, &source, &title, 1);
2439
2440 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2441 assert_eq!(
2442 result.moved_count(),
2443 1,
2444 "errors: {:?}",
2445 result.failure_messages()
2446 );
2447 let name = result
2448 .moves()
2449 .next()
2450 .unwrap()
2451 .dest()
2452 .file_name()
2453 .unwrap()
2454 .to_string_lossy();
2455 assert!(name.len() <= MAX_FILE_NAME_BYTES);
2456 assert!(name.ends_with(".flac"));
2457 assert!(result.moves().next().unwrap().dest().exists());
2458 }
2459
2460 #[test]
2463 fn remove_empty_dirs_never_climbs_past_a_floor() {
2464 let tmp = TempDir::new().unwrap();
2465 let root = tmp.path().join("library");
2466 let nested = root.join("artist/album");
2467 std::fs::create_dir_all(&nested).unwrap();
2468
2469 remove_empty_dirs(&nested, std::slice::from_ref(&root));
2470
2471 assert!(!nested.exists());
2472 assert!(!root.join("artist").exists());
2473 assert!(root.exists(), "the library root must survive");
2474 }
2475
2476 #[test]
2477 fn remove_empty_dirs_stays_put_outside_any_floor() {
2478 let tmp = TempDir::new().unwrap();
2479 let outside = tmp.path().join("incoming/rip");
2480 std::fs::create_dir_all(&outside).unwrap();
2481
2482 remove_empty_dirs(&outside, &[tmp.path().join("library")]);
2483
2484 assert!(!outside.exists());
2485 assert!(
2486 tmp.path().join("incoming").exists(),
2487 "no floor means no climbing"
2488 );
2489 }
2490
2491 #[test]
2492 fn remove_empty_dirs_never_removes_a_floor_itself() {
2493 let tmp = TempDir::new().unwrap();
2494 let root = tmp.path().join("library");
2495 std::fs::create_dir_all(&root).unwrap();
2496
2497 remove_empty_dirs(&root, std::slice::from_ref(&root));
2498
2499 assert!(root.exists());
2500 }
2501
2502 #[test]
2505 fn undo_refuses_when_the_original_path_is_occupied() {
2506 let db = test_db();
2507 let tmp = TempDir::new().unwrap();
2508 let source = tmp.path().join("src/test.flac");
2509 add_track(&db, &source, "Airbag", 1);
2510
2511 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2512 let dest = result.moves().next().unwrap().dest().to_path_buf();
2513
2514 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2516 std::fs::write(&source, b"a completely different rip").unwrap();
2517
2518 let undone = undo(&db).unwrap();
2519 assert_eq!(undone.restored, 0);
2520 assert_eq!(undone.errors.len(), 1);
2521 assert_eq!(
2522 std::fs::read(&source).unwrap(),
2523 b"a completely different rip".to_vec()
2524 );
2525 assert!(dest.exists());
2526 assert_eq!(log_rows(&db).len(), 1);
2528 }
2529
2530 #[test]
2531 fn undo_refuses_when_the_moved_file_has_been_replaced() {
2532 let db = test_db();
2533 let tmp = TempDir::new().unwrap();
2534 let source = tmp.path().join("src/test.flac");
2535 add_track(&db, &source, "Airbag", 1);
2536
2537 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2538 let dest = result.moves().next().unwrap().dest().to_path_buf();
2539 std::fs::write(&dest, b"replaced with something else entirely").unwrap();
2540
2541 let undone = undo(&db).unwrap();
2542 assert_eq!(undone.restored, 0);
2543 assert_eq!(undone.errors.len(), 1);
2544 assert!(!source.exists());
2545 assert!(dest.exists());
2546 }
2547
2548 #[test]
2551 fn undo_keeps_moves_onto_a_volume_that_is_away() {
2552 let db = test_db();
2553 let tmp = TempDir::new().unwrap();
2554 let source = tmp.path().join("src/test.flac");
2555 add_track(&db, &source, "Airbag", 1);
2556 let volume = tmp.path().join("volume");
2557 std::fs::create_dir_all(&volume).unwrap();
2558
2559 execute(&db, "%album artist%/%album%/%title%", Some(&volume)).unwrap();
2560 let away = tmp.path().join("away");
2561 std::fs::rename(&volume, &away).unwrap();
2562
2563 let floors = [volume.clone()];
2564 let undone = undo_within(&db, &floors).unwrap();
2565 assert_eq!((undone.restored, undone.errors.len()), (0, 1));
2566 assert_eq!(log_rows(&db).len(), 1, "kept for when the volume is back");
2567
2568 std::fs::rename(&away, &volume).unwrap();
2569 assert_eq!(undo_within(&db, &floors).unwrap().restored, 1);
2570 assert!(source.exists());
2571 }
2572
2573 #[test]
2576 fn undo_forgets_an_album_deleted_by_hand() {
2577 let db = test_db();
2578 let tmp = TempDir::new().unwrap();
2579 let library = tmp.path().join("library");
2580 let older = tmp.path().join("src/older.flac");
2581 add_track(&db, &older, "Airbag", 1);
2582 std::fs::create_dir_all(library.join("keep")).unwrap();
2583 execute(&db, "%album artist%/%album%/%title%", Some(&library)).unwrap();
2584
2585 let newer = tmp.path().join("src/newer.flac");
2586 let mut meta = sample_meta("Lucky", "Radiohead", "OK Computer");
2587 meta.path = Some(newer.to_string_lossy().into_owned());
2588 std::fs::create_dir_all(newer.parent().unwrap()).unwrap();
2590 std::fs::write(&newer, b"lucky").unwrap();
2591 queries::upsert_track(&db.conn, &meta).unwrap();
2592 let result = run(
2593 &db,
2594 Selection::Paths(std::slice::from_ref(&newer)),
2595 "%album artist%/Elsewhere/%title%",
2596 &library,
2597 )
2598 .unwrap();
2599 let dest = result.moves().next().unwrap().dest().to_path_buf();
2600 std::fs::remove_dir_all(dest.parent().unwrap()).unwrap();
2601
2602 let floors = [library.clone()];
2603 let undone = undo_within(&db, &floors).unwrap();
2604 assert_eq!((undone.restored, undone.errors.len()), (0, 0));
2605 assert_eq!(undo_within(&db, &floors).unwrap().restored, 1);
2606 assert!(older.exists());
2607 }
2608
2609 #[test]
2613 fn undoing_an_interrupted_run_leaves_its_lists_intact() {
2614 let db = test_db();
2615 let tmp = TempDir::new().unwrap();
2616 let album = tmp.path().join("src/Album");
2617 add_track(&db, &album.join("01.flac"), "Airbag", 1);
2618 add_track(&db, &album.join("02.flac"), "Paranoid Android", 2);
2619 let list = album.join("album.m3u");
2620 std::fs::write(&list, "01.flac\n02.flac\n").unwrap();
2621
2622 let base = tmp.path().join("library");
2624 let planned = plan(
2625 &db,
2626 Selection::All,
2627 "%album artist%/%album%/%title%",
2628 &base,
2629 true,
2630 )
2631 .unwrap();
2632 let first = planned
2633 .entries
2634 .iter()
2635 .filter_map(PlanEntry::as_move)
2636 .find(|m| m.ancillary.iter().any(|(from, _)| from == &list))
2637 .expect("the list travels with one of the moves");
2638 execute_single_move(
2639 &db,
2640 &first,
2641 "batch-interrupted",
2642 std::slice::from_ref(&base),
2643 )
2644 .unwrap();
2645
2646 let undone = undo_within(&db, &[base]).unwrap();
2647 assert!(undone.errors.is_empty(), "{:?}", undone.errors);
2648 assert_eq!(
2649 std::fs::read_to_string(&list).unwrap(),
2650 "01.flac\n02.flac\n"
2651 );
2652 }
2653
2654 #[test]
2658 fn playlist_files_follow_what_organize_moves() {
2659 use crate::index::playlist_files;
2660 let db = test_db();
2661 let tmp = TempDir::new().unwrap();
2662 let album = tmp.path().join("src/Album");
2663 let first = album.join("01.flac");
2664 let second = album.join("02.flac");
2665 let a = add_track(&db, &first, "Airbag", 1);
2666 let b = add_track(&db, &second, "Paranoid Android", 2);
2667 let beside = album.join("album.m3u");
2668 std::fs::write(&beside, "#EXTM3U\n01.flac\n02.flac\n").unwrap();
2669 let elsewhere = tmp.path().join("lists/mix.m3u8");
2670 std::fs::create_dir_all(elsewhere.parent().unwrap()).unwrap();
2671 std::fs::write(&elsewhere, format!("{}\n", second.display())).unwrap();
2672 playlist_files::import(&db, &[beside.clone(), elsewhere.clone()], &[]);
2673
2674 let lists = |db: &Database| -> Vec<(i64, String, String, Vec<i64>)> {
2675 queries::list_playlists(&db.conn, queries::LOCAL_USER)
2676 .unwrap()
2677 .into_iter()
2678 .map(|p| {
2679 let tracks = queries::playlist_track_ids(&db.conn, p.id).unwrap();
2680 (p.id, p.uid, p.source_path.unwrap(), tracks)
2681 })
2682 .collect()
2683 };
2684 let before = lists(&db);
2685 assert_eq!(before.len(), 2);
2686
2687 let base = tmp.path().join("library");
2688 execute(&db, "%album artist%/%album%/%title%", Some(&base)).unwrap();
2689 let moved_list = base.join("Radiohead/OK Computer/album.m3u");
2690 assert_eq!(
2691 std::fs::read_to_string(&moved_list).unwrap(),
2692 "#EXTM3U\nAirbag.flac\nParanoid Android.flac\n"
2693 );
2694
2695 playlist_files::import(
2697 &db,
2698 &[moved_list.clone(), elsewhere.clone()],
2699 &[tmp.path().to_path_buf()],
2700 );
2701 let after = lists(&db);
2702 assert_eq!(after.len(), 2, "{after:#?}");
2703 for (was, now) in before.iter().zip(&after) {
2704 assert_eq!((was.0, &was.1, &was.3), (now.0, &now.1, &now.3));
2705 }
2706 assert!(after.iter().any(|l| Path::new(&l.2) == moved_list));
2707 assert!(after.iter().any(|l| l.3 == [a, b]));
2708
2709 let undone = undo(&db).unwrap();
2710 assert!(undone.errors.is_empty(), "{:?}", undone.errors);
2711 assert_eq!(
2712 std::fs::read_to_string(&beside).unwrap(),
2713 "#EXTM3U\n01.flac\n02.flac\n"
2714 );
2715 assert_eq!(
2716 std::fs::read_to_string(&elsewhere).unwrap(),
2717 format!("{}\n", second.display())
2718 );
2719 }
2720
2721 #[test]
2723 fn undo_takes_the_newest_batch_when_timestamps_tie() {
2724 let db = test_db();
2725 let tmp = TempDir::new().unwrap();
2726 let older = tmp.path().join("older.flac");
2727 let newer = tmp.path().join("newer.flac");
2728 std::fs::write(&older, b"older").unwrap();
2729 std::fs::write(&newer, b"newer").unwrap();
2730 let moved_older = tmp.path().join("moved-older.flac");
2731 let moved_newer = tmp.path().join("moved-newer.flac");
2732 std::fs::rename(&older, &moved_older).unwrap();
2733 std::fs::rename(&newer, &moved_newer).unwrap();
2734
2735 for (batch, from, to) in [
2736 ("batch-1", &older, &moved_older),
2737 ("batch-2", &newer, &moved_newer),
2738 ] {
2739 db.conn
2740 .execute(
2741 "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, created_at)
2742 VALUES (?1, NULL, ?2, ?3, '2025-01-01 00:00:00')",
2743 params![
2744 batch,
2745 from.to_string_lossy().as_ref(),
2746 to.to_string_lossy().as_ref()
2747 ],
2748 )
2749 .unwrap();
2750 }
2751
2752 let undone = undo(&db).unwrap();
2753 assert_eq!(undone.restored, 1);
2754 assert!(newer.exists(), "the newest batch is the one undone");
2755 assert!(!older.exists());
2756 }
2757
2758 #[test]
2761 fn favourites_and_queue_state_follow_the_move() {
2762 let db = test_db();
2763 let tmp = TempDir::new().unwrap();
2764 let source = tmp.path().join("src/test.flac");
2765 add_track(&db, &source, "Airbag", 1);
2766 let source_str = source.to_string_lossy().into_owned();
2767
2768 let track = queries::track_id_by_path(&db.conn, &source_str)
2769 .unwrap()
2770 .unwrap();
2771 queries::add_favourite(&db.conn, crate::db::queries::LOCAL_USER, track).unwrap();
2772 let item = PersistedQueueItem {
2773 uid: None,
2774 path: source_str.clone(),
2775 title: "Airbag".into(),
2776 artist: "Radiohead".into(),
2777 album_artist: "Radiohead".into(),
2778 album: "OK Computer".into(),
2779 year: None,
2780 codec: None,
2781 track_number: Some(1),
2782 disc: Some(1),
2783 duration_ms: None,
2784 db_id: None,
2785 played: None,
2786 };
2787 queries::save_playback_state(
2788 &db.conn,
2789 &[item],
2790 Default::default(),
2791 Some(&source_str),
2792 0,
2793 false,
2794 )
2795 .unwrap();
2796
2797 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2798 let dest = result.moves().next().unwrap().dest().to_path_buf();
2799 let dest_str = dest.to_string_lossy().into_owned();
2800
2801 let favourites =
2802 queries::load_favourites(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2803 assert!(favourites.contains(&track));
2804 assert_eq!(
2805 queries::track_id_by_path(&db.conn, &dest_str).unwrap(),
2806 Some(track)
2807 );
2808
2809 let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
2810 assert_eq!(state.items[0].path, dest_str);
2811 assert_eq!(state.cursor_path.as_deref(), Some(dest_str.as_str()));
2812
2813 assert_eq!(undo(&db).unwrap().restored, 1);
2814
2815 let favourites =
2816 queries::load_favourites(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2817 assert!(favourites.contains(&track));
2818 let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
2819 assert_eq!(state.items[0].path, source_str);
2820 assert_eq!(state.cursor_path.as_deref(), Some(source_str.as_str()));
2821 }
2822
2823 #[test]
2824 fn scan_cache_follows_the_move() {
2825 let db = test_db();
2826 let tmp = TempDir::new().unwrap();
2827 let source = tmp.path().join("src/test.flac");
2828 let id = add_track(&db, &source, "Airbag", 1);
2829 db.conn
2830 .execute(
2831 "INSERT INTO scan_cache (path, mtime, size, track_id) VALUES (?1, 1, 1, ?2)",
2832 params![source.to_string_lossy().as_ref(), id],
2833 )
2834 .unwrap();
2835
2836 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2837 let dest = result
2838 .moves()
2839 .next()
2840 .unwrap()
2841 .dest()
2842 .to_string_lossy()
2843 .into_owned();
2844
2845 let cached: String = db
2846 .conn
2847 .query_row(
2848 "SELECT path FROM scan_cache WHERE track_id = ?1",
2849 params![id],
2850 |r| r.get(0),
2851 )
2852 .unwrap();
2853 assert_eq!(cached, dest);
2854 }
2855
2856 #[test]
2859 fn partial_failure_leaves_the_database_and_result_consistent() {
2860 let db = test_db();
2861 let tmp = TempDir::new().unwrap();
2862 let first = tmp.path().join("src/a.flac");
2863 let clash = tmp.path().join("src/b.flac");
2864 let third = tmp.path().join("src/c.flac");
2865 let first_id = add_track(&db, &first, "Airbag", 1);
2866 let clash_id = add_track(&db, &clash, "Airbag", 2);
2867 let third_id = add_track(&db, &third, "Karma Police", 3);
2868
2869 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2870
2871 assert_eq!(result.moved_count(), 2);
2872 assert_eq!(result.failures().count(), 1);
2873
2874 let logged = log_rows(&db);
2875 assert_eq!(logged.len(), 2);
2876 for file_move in result.moves() {
2877 assert!(file_move.dest().exists());
2878 assert!(
2879 logged
2880 .iter()
2881 .any(|(_, _, to)| Path::new(to) == file_move.dest())
2882 );
2883 }
2884
2885 assert!(clash.exists());
2887 assert_eq!(
2888 db_path_of(&db, clash_id).as_deref(),
2889 Some(clash.to_str().unwrap())
2890 );
2891 assert_ne!(db_path_of(&db, first_id).as_deref(), first.to_str());
2892 assert_ne!(db_path_of(&db, third_id).as_deref(), third.to_str());
2893 }
2894
2895 #[test]
2898 fn unknown_paths_are_logged_and_undoable() {
2899 let db = test_db();
2900 let tmp = TempDir::new().unwrap();
2901 let known = tmp.path().join("src/known.flac");
2902 add_track(&db, &known, "Airbag", 1);
2903
2904 let result = run(
2905 &db,
2906 Selection::Paths(std::slice::from_ref(&known)),
2907 "%album artist%/%album%/%title%",
2908 tmp.path(),
2909 )
2910 .unwrap();
2911
2912 assert_eq!(result.moved_count(), 1);
2913 let logged = log_rows(&db);
2914 assert_eq!(logged.len(), 1);
2915 assert!(logged[0].0.is_some());
2916
2917 assert_eq!(undo(&db).unwrap().restored, 1);
2918 assert!(known.exists());
2919 }
2920
2921 #[test]
2922 fn ancillary_files_move_with_the_album() {
2923 let db = test_db();
2924 let tmp = TempDir::new().unwrap();
2925 let source = tmp.path().join("src/test.flac");
2926 add_track(&db, &source, "Airbag", 1);
2927 std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2928
2929 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2930 assert_eq!(result.moved_count(), 1);
2931 let dest_dir = result.moves().next().unwrap().dest().parent().unwrap();
2932 assert!(dest_dir.join("cover.jpg").exists());
2933
2934 assert_eq!(log_rows(&db).len(), 2);
2936 assert_eq!(undo(&db).unwrap().restored, 2);
2937 assert!(source.parent().unwrap().join("cover.jpg").exists());
2938 }
2939
2940 #[test]
2943 fn extension_not_clobbered_by_dots_in_title() {
2944 let db = test_db();
2947 let tmp = TempDir::new().unwrap();
2948 let source = tmp.path().join("src/CHROMA 011 A.L.O.E II.flac");
2949 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2950 std::fs::write(&source, b"fake").unwrap();
2951
2952 let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
2953 meta.track_number = Some(10);
2954 meta.date = Some("2025-11-21".into());
2955 meta.path = Some(source.to_string_lossy().into_owned());
2956 queries::upsert_track(&db.conn, &meta).unwrap();
2957
2958 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2959 let result = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2960 assert_eq!(result.moved_count(), 1);
2961 assert_eq!(
2962 result
2963 .moves()
2964 .next()
2965 .unwrap()
2966 .dest()
2967 .file_name()
2968 .unwrap()
2969 .to_string_lossy(),
2970 "0110. Bicep - CHROMA 011 A.L.O.E II.flac"
2971 );
2972 }
2973
2974 #[test]
2975 fn extension_preserved_for_tracknumber_dot() {
2976 let db = test_db();
2978 let tmp = TempDir::new().unwrap();
2979 let source = tmp.path().join("src/CHROMA 012 TANGZ II.flac");
2980 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2981 std::fs::write(&source, b"fake").unwrap();
2982
2983 let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
2984 meta.track_number = Some(11);
2985 meta.date = Some("2025-11-21".into());
2986 meta.path = Some(source.to_string_lossy().into_owned());
2987 queries::upsert_track(&db.conn, &meta).unwrap();
2988
2989 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2990 let result = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2991 assert_eq!(result.moved_count(), 1);
2992 assert_eq!(
2993 result
2994 .moves()
2995 .next()
2996 .unwrap()
2997 .dest()
2998 .file_name()
2999 .unwrap()
3000 .to_string_lossy(),
3001 "0111. Bicep - CHROMA 012 TANGZ II.flac"
3002 );
3003 }
3004}