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 }
1664 }
1665
1666 fn sample_track_row(title: &str, artist: &str, album: &str) -> TrackRow {
1667 TrackRow {
1668 id: 1,
1669 album_id: Some(1),
1670 artist_id: Some(1),
1671 artist_name: artist.into(),
1672 album_artist_name: artist.into(),
1673 album_title: album.into(),
1674 disc: Some(1),
1675 track_number: Some(1),
1676 title: title.into(),
1677 duration_ms: Some(240_000),
1678 path: Some("/music/test.flac".into()),
1679 codec: Some("FLAC".into()),
1680 sample_rate: Some(44100),
1681 bit_depth: Some(16),
1682 channels: Some(2),
1683 bitrate: Some(1000),
1684 genre: None,
1685 source: "local".into(),
1686 remote_id: None,
1687 cached_path: None,
1688 }
1689 }
1690
1691 fn add_track(db: &Database, path: &Path, title: &str, track_number: i32) -> i64 {
1693 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1694 std::fs::write(path, format!("audio bytes for {title}")).unwrap();
1695 let mut meta = sample_meta(title, "Radiohead", "OK Computer");
1696 meta.track_number = Some(track_number);
1697 meta.path = Some(path.to_string_lossy().into_owned());
1698 queries::upsert_track(&db.conn, &meta).unwrap()
1699 }
1700
1701 fn db_path_of(db: &Database, track_id: i64) -> Option<String> {
1702 db.conn
1703 .query_row(
1704 "SELECT path FROM tracks WHERE id = ?1",
1705 params![track_id],
1706 |row| row.get(0),
1707 )
1708 .unwrap()
1709 }
1710
1711 fn log_rows(db: &Database) -> Vec<(Option<i64>, String, String)> {
1712 let mut stmt = db
1713 .conn
1714 .prepare("SELECT track_id, from_path, to_path FROM organize_log ORDER BY id")
1715 .unwrap();
1716 let rows = stmt
1717 .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1718 .unwrap();
1719 rows.map(|r| r.unwrap()).collect()
1720 }
1721
1722 #[test]
1725 fn track_metadata_provider_fields() {
1726 let mut track = sample_track_row("Subterranean Homesick Alien", "Radiohead", "OK Computer");
1727 track.track_number = Some(3);
1728 track.genre = Some("Alternative".into());
1729
1730 let album = AlbumFacts {
1731 date: Some("1997-06-16".into()),
1732 label: Some("Parlophone".into()),
1733 };
1734 let meta = TrackMetadata::from_track_row(&track, &album);
1735 assert_eq!(
1736 meta.get_field("title").as_deref(),
1737 Some("Subterranean Homesick Alien")
1738 );
1739 assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
1740 assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
1741 assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
1742 assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
1743 assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
1744 assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
1745 assert_eq!(meta.get_field("label").as_deref(), Some("Parlophone"));
1746 assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
1747 assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
1748 assert_eq!(meta.get_field("nonexistent"), None);
1749 }
1750
1751 #[test]
1754 fn both_metadata_sources_expose_the_same_fields() {
1755 let mut track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1756 track.genre = Some("Rock".into());
1757 let album = AlbumFacts {
1758 date: Some("1997-06-16".into()),
1759 label: Some("Parlophone".into()),
1760 };
1761 let from_db = TrackMetadata::from_track_row(&track, &album);
1762
1763 let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1764 meta.label = Some("Parlophone".into());
1765 let from_tags = TrackMetadata::from_file_meta(&meta);
1766
1767 let mut db_fields: Vec<&String> = from_db.fields.keys().collect();
1768 let mut tag_fields: Vec<&String> = from_tags.fields.keys().collect();
1769 db_fields.sort();
1770 tag_fields.sort();
1771 assert_eq!(db_fields, tag_fields);
1772 }
1773
1774 #[test]
1775 fn sanitize_replaces_illegal_chars() {
1776 assert_eq!(sanitise_filename("AC/DC"), "AC_DC");
1777 assert_eq!(sanitise_filename("What?"), "What_");
1778 assert_eq!(sanitise_filename("a:b*c"), "a_b_c");
1779 assert_eq!(sanitise_filename("normal"), "normal");
1780 }
1781
1782 #[test]
1783 fn sanitize_relative_path_splits() {
1784 assert_eq!(
1785 sanitize_relative_path("Artist/Album/Track").unwrap(),
1786 PathBuf::from("Artist/Album/Track")
1787 );
1788 assert_eq!(
1789 sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag").unwrap(),
1790 PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
1791 );
1792 }
1793
1794 #[test]
1795 fn sanitize_relative_path_refuses_traversal_and_gaps() {
1796 assert!(sanitize_relative_path("../../../../etc/passwd").is_err());
1799 assert!(sanitize_relative_path("Artist/../../../outside").is_err());
1800 assert!(sanitize_relative_path("./Artist/./Album").is_err());
1801 assert!(sanitize_relative_path("Radiohead/OK Computer/").is_err());
1802 assert!(sanitize_relative_path("Radiohead//Airbag").is_err());
1803 assert!(sanitize_relative_path(" /Airbag").is_err());
1804 }
1805
1806 #[test]
1807 fn acdc_artist_name_sanitized() {
1808 let track = sample_track_row("Highway to Hell", "AC/DC", "Highway to Hell");
1809 let meta = TrackMetadata::from_track_row(&track, &AlbumFacts::default());
1810 assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
1811 let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
1812 assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
1813 }
1814
1815 #[test]
1816 fn format_string_evaluation() {
1817 let track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1818 let album = AlbumFacts {
1819 date: Some("1997-06-16".into()),
1820 label: None,
1821 };
1822 let meta = TrackMetadata::from_track_row(&track, &album);
1823 let pattern =
1824 "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
1825 assert_eq!(
1826 format::format(pattern, &meta).unwrap(),
1827 "Radiohead/(1997) OK Computer/01. Airbag"
1828 );
1829 }
1830
1831 #[test]
1832 fn ancillary_file_detection() {
1833 let tmp = TempDir::new().unwrap();
1834 let dir = tmp.path();
1835 std::fs::write(dir.join("cover.jpg"), b"img").unwrap();
1836 std::fs::write(dir.join("cover.png"), b"img").unwrap();
1837 std::fs::write(dir.join("album.cue"), b"cue").unwrap();
1838 std::fs::write(dir.join("rip.log"), b"log").unwrap();
1839 std::fs::write(dir.join("track.flac"), b"audio").unwrap();
1840
1841 let found = find_ancillary_files(dir);
1842 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1843 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1844 assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1845 assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1846 assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1847 }
1848
1849 #[test]
1852 fn preview_does_not_move_files() {
1853 let db = test_db();
1854 let tmp = TempDir::new().unwrap();
1855 let source = tmp.path().join("src/test.flac");
1856 add_track(&db, &source, "Airbag", 1);
1857
1858 let result = preview(
1859 &db,
1860 "%album artist%/%album%/%title%",
1861 Some(tmp.path()),
1862 true,
1863 )
1864 .unwrap();
1865 assert!(source.exists());
1866 assert_eq!(result.moved_count(), 1);
1867 }
1868
1869 #[test]
1870 fn execute_moves_files_and_undo_reverts() {
1871 let db = test_db();
1872 let tmp = TempDir::new().unwrap();
1873 let source = tmp.path().join("src/test.flac");
1874 let id = add_track(&db, &source, "Airbag", 1);
1875
1876 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1877 assert_eq!(result.moved_count(), 1);
1878 assert_eq!(result.failures().count(), 0);
1879 assert!(!source.exists());
1880 let dest = result.moves().next().unwrap().dest().to_path_buf();
1881 assert!(dest.exists());
1882 assert_eq!(db_path_of(&db, id).as_deref(), Some(dest.to_str().unwrap()));
1883
1884 let undone = undo(&db).unwrap();
1885 assert_eq!(undone.restored, 1);
1886 assert!(undone.errors.is_empty());
1887 assert!(source.exists());
1888 assert!(!dest.exists());
1889 assert_eq!(
1890 db_path_of(&db, id).as_deref(),
1891 Some(source.to_str().unwrap())
1892 );
1893 }
1894
1895 #[test]
1899 fn preview_and_execute_agree_on_destinations() {
1900 let db = test_db();
1901 let tmp = TempDir::new().unwrap();
1902 let pattern = "$if2(%label%,%album artist%)/%album%/[$num(%tracknumber%,2). ]%title%";
1903
1904 let source = tmp.path().join("src/aphex.flac");
1905 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1906 std::fs::write(&source, b"audio").unwrap();
1907 let mut meta = sample_meta("Xtal", "Aphex Twin", "Selected Ambient Works");
1908 meta.label = Some("Warp Records".into());
1909 meta.path = Some(source.to_string_lossy().into_owned());
1910 queries::upsert_track(&db.conn, &meta).unwrap();
1911
1912 let previewed = preview(&db, pattern, Some(tmp.path()), true).unwrap();
1913 assert_eq!(previewed.moved_count(), 1);
1914 let expected = previewed.moves().next().unwrap().dest().to_path_buf();
1915 assert!(expected.starts_with(tmp.path().join("Warp Records")));
1916
1917 let executed = execute(&db, pattern, Some(tmp.path())).unwrap();
1918 assert_eq!(executed.moved_count(), 1);
1919 assert_eq!(executed.moves().next().unwrap().dest(), expected);
1920 assert!(expected.exists());
1921 }
1922
1923 #[test]
1927 fn imported_files_organize_into_the_library_folder() {
1928 let db = test_db();
1929 let tmp = TempDir::new().unwrap();
1930 let outside = tmp.path().join("Downloads/rip");
1931 let library = tmp.path().join("Music");
1932 std::fs::create_dir_all(&outside).unwrap();
1933 std::fs::create_dir_all(&library).unwrap();
1934
1935 let dropped = outside.join("track.wav");
1936 crate::test_utils::generate_wav(&dropped, 44100, 1, 0.2, 16);
1937
1938 let imported = crate::index::scanner::import_paths(&db, std::slice::from_ref(&outside));
1939 assert_eq!(imported.track_ids.len(), 1, "errors: {:?}", imported.errors);
1940
1941 let result = execute_for_tracks(
1942 &db,
1943 &imported.track_ids,
1944 "%album artist%/%album%/%title%",
1945 Some(&library),
1946 )
1947 .unwrap();
1948
1949 assert_eq!(result.moved_count(), 1);
1950 let dest = result.moves().next().unwrap().dest();
1951 assert!(dest.starts_with(&library), "landed at {}", dest.display());
1952 assert!(dest.exists());
1953 assert!(
1954 !dropped.exists(),
1955 "the original should have moved, not copied"
1956 );
1957
1958 assert_eq!(
1960 db_path_of(&db, imported.track_ids[0]).as_deref(),
1961 dest.to_str()
1962 );
1963 }
1964
1965 #[test]
1970 fn an_organized_file_is_stored_as_the_disk_spells_it() {
1971 use unicode_normalization::UnicodeNormalization;
1972 let db = test_db();
1973 let tmp = TempDir::new().unwrap();
1974 let library = tmp.path().join("Music");
1975 let artist_on_disk: String = "Björk".nfd().collect();
1976 std::fs::create_dir_all(library.join(&artist_on_disk)).unwrap();
1977
1978 let source = tmp.path().join("in/track.wav");
1979 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1980 crate::test_utils::generate_wav(&source, 44100, 1, 0.2, 16);
1981 let imported = crate::index::scanner::import_paths(&db, std::slice::from_ref(&source));
1982 assert_eq!(imported.track_ids.len(), 1, "errors: {:?}", imported.errors);
1983
1984 let result = execute_for_tracks(
1985 &db,
1986 &imported.track_ids,
1987 &format!("{}/%title%", "Björk".nfc().collect::<String>()),
1988 Some(&library),
1989 )
1990 .unwrap();
1991 assert_eq!(result.moved_count(), 1, "{:?}", result.failures().count());
1992
1993 crate::index::scanner::scan_folder(
1994 &db,
1995 &library,
1996 crate::index::scanner::ScanOptions::default(),
1997 None,
1998 );
1999 let paths: Vec<String> = db
2000 .conn
2001 .prepare("SELECT path FROM local_files")
2002 .unwrap()
2003 .query_map([], |r| r.get(0))
2004 .unwrap()
2005 .collect::<Result<_, _>>()
2006 .unwrap();
2007 assert_eq!(paths.len(), 1, "one file, one row: {paths:?}");
2008 assert_eq!(
2009 db_path_of(&db, imported.track_ids[0]).as_ref(),
2010 paths.first()
2011 );
2012 }
2013
2014 #[cfg(target_os = "macos")]
2017 #[test]
2018 fn an_organized_file_is_stored_in_the_folders_own_case() {
2019 let db = test_db();
2020 let tmp = TempDir::new().unwrap();
2021 let library = tmp.path().join("Music");
2022 std::fs::create_dir_all(library.join("The beatles")).unwrap();
2023 if !library.join("THE BEATLES").exists() {
2024 return;
2025 }
2026
2027 let source = tmp.path().join("in/track.wav");
2028 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2029 crate::test_utils::generate_wav(&source, 44100, 1, 0.2, 16);
2030 let imported = crate::index::scanner::import_paths(&db, std::slice::from_ref(&source));
2031 assert_eq!(imported.track_ids.len(), 1, "errors: {:?}", imported.errors);
2032
2033 let result = execute_for_tracks(
2034 &db,
2035 &imported.track_ids,
2036 "The Beatles/%title%",
2037 Some(&library),
2038 )
2039 .unwrap();
2040 assert_eq!(result.moved_count(), 1);
2041
2042 crate::index::scanner::scan_folder(
2043 &db,
2044 &library,
2045 crate::index::scanner::ScanOptions::default(),
2046 None,
2047 );
2048 let paths: Vec<String> = db
2049 .conn
2050 .prepare("SELECT path FROM local_files")
2051 .unwrap()
2052 .query_map([], |r| r.get(0))
2053 .unwrap()
2054 .collect::<Result<_, _>>()
2055 .unwrap();
2056 assert_eq!(paths.len(), 1, "one file, one row: {paths:?}");
2057 assert!(paths[0].contains("/The beatles/"), "{paths:?}");
2058 }
2059
2060 #[test]
2065 fn generate_touches_no_files() {
2066 let db = test_db();
2067 let tmp = TempDir::new().unwrap();
2068 let source = tmp.path().join("src/track.flac");
2069 add_track(&db, &source, "Airbag", 1);
2070 std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2071
2072 let occupied = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
2074 std::fs::create_dir_all(occupied.parent().unwrap()).unwrap();
2075 std::fs::write(&occupied, b"the good rip").unwrap();
2076
2077 let selection = resolve(&db, None).unwrap();
2078 let mut result = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
2079
2080 assert_eq!(result.moved_count(), 1);
2082 assert_eq!(result.conflicts().count(), 0);
2083 assert!(result.entries[0].ancillary.is_empty());
2084
2085 check_against_disk(&mut result, true);
2087 assert_eq!(result.moved_count(), 0);
2088 assert_eq!(result.conflicts().count(), 1);
2089 assert_eq!(result.conflicts().next().unwrap().dest(), occupied);
2090 }
2091
2092 #[test]
2097 fn an_empty_base_dir_is_not_a_destination() {
2098 let db = test_db();
2099 add_track(&db, Path::new("/tmp/src/a.flac"), "Airbag", 1);
2100
2101 let err = preview(&db, "%title%", Some(Path::new("")), false).unwrap_err();
2102 assert!(matches!(err, OrganizeError::NoDestination));
2103
2104 let err = execute(&db, "%title%", Some(Path::new(""))).unwrap_err();
2105 assert!(matches!(err, OrganizeError::NoDestination));
2106 }
2107
2108 #[test]
2111 fn generate_agrees_with_a_full_plan() {
2112 let db = test_db();
2113 let tmp = TempDir::new().unwrap();
2114 add_track(&db, &tmp.path().join("src/a.flac"), "Airbag", 1);
2115 add_track(&db, &tmp.path().join("src/b.flac"), "Karma Police", 2);
2116 let pattern = "%album artist%/%album%/%tracknumber%. %title%";
2117
2118 let selection = resolve(&db, None).unwrap();
2119 let mut generated = generate(&selection, pattern, tmp.path());
2120 check_against_disk(&mut generated, true);
2121 let planned = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2122
2123 assert_eq!(generated.entries.len(), planned.entries.len());
2124 for (a, b) in generated.entries.iter().zip(&planned.entries) {
2125 assert_eq!(a.from, b.from);
2126 assert_eq!(a.to, b.to);
2127 assert_eq!(a.outcome, b.outcome);
2128 assert_eq!(a.ancillary, b.ancillary);
2129 }
2130 }
2131
2132 #[test]
2135 fn ancillary_is_scanned_once_per_directory() {
2136 let db = test_db();
2137 let tmp = TempDir::new().unwrap();
2138 for (i, title) in ["Airbag", "Karma Police", "Lucky"].iter().enumerate() {
2139 add_track(
2140 &db,
2141 &tmp.path().join(format!("src/{i}.flac")),
2142 title,
2143 i as i32 + 1,
2144 );
2145 }
2146 std::fs::write(tmp.path().join("src/cover.jpg"), b"art").unwrap();
2147
2148 let result = preview(
2149 &db,
2150 "%album artist%/%album%/%title%",
2151 Some(tmp.path()),
2152 true,
2153 )
2154 .unwrap();
2155
2156 let carrying: Vec<_> = result.moves().filter(|e| !e.ancillary.is_empty()).collect();
2158 assert_eq!(carrying.len(), 1);
2159 assert_eq!(carrying[0].ancillary.len(), 1);
2160 }
2161
2162 #[test]
2167 fn a_file_already_at_its_destination_is_unchanged_not_a_conflict() {
2168 let db = test_db();
2169 let tmp = TempDir::new().unwrap();
2170 let pattern = "%album artist%/%album%/%title%";
2171 add_track(&db, &tmp.path().join("src/a.flac"), "Airbag", 1);
2172
2173 let moved = execute(&db, pattern, Some(tmp.path())).unwrap();
2174 assert_eq!(moved.moved_count(), 1);
2175
2176 let again = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2178 assert_eq!(again.conflicts().count(), 0);
2179 assert_eq!(again.unchanged_count(), 1);
2180 }
2181
2182 #[test]
2183 fn ancillary_files_stay_put_when_they_are_turned_off() {
2184 let db = test_db();
2185 let tmp = TempDir::new().unwrap();
2186 let source = tmp.path().join("src/a.flac");
2187 add_track(&db, &source, "Airbag", 1);
2188 std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2189
2190 let selection = resolve(&db, None).unwrap();
2191 let mut off = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
2192 check_against_disk(&mut off, false);
2193 assert!(off.moves().all(|e| e.ancillary.is_empty()));
2194
2195 let mut on = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
2196 check_against_disk(&mut on, true);
2197 assert_eq!(on.moves().next().unwrap().ancillary.len(), 1);
2198 }
2199
2200 #[test]
2203 fn colliding_destinations_leave_both_files_intact() {
2204 let db = test_db();
2205 let tmp = TempDir::new().unwrap();
2206 let first = tmp.path().join("src/a.flac");
2207 let second = tmp.path().join("src/b.flac");
2208 let first_id = add_track(&db, &first, "Airbag", 1);
2210 let second_id = add_track(&db, &second, "Airbag", 2);
2211 let second_bytes = std::fs::read(&second).unwrap();
2212
2213 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2214
2215 assert_eq!(result.moved_count(), 1);
2216
2217 let blocked = result.conflicts().next().unwrap();
2220 assert_eq!(result.conflicts().count(), 1);
2221 assert_eq!(blocked.from, second);
2222 assert_eq!(blocked.dest(), result.moves().next().unwrap().dest());
2223
2224 assert!(second.exists());
2226 assert_eq!(std::fs::read(&second).unwrap(), second_bytes);
2227 assert_eq!(
2228 db_path_of(&db, second_id).as_deref(),
2229 Some(second.to_str().unwrap())
2230 );
2231
2232 let dest = result.moves().next().unwrap().dest();
2233 assert_eq!(
2234 std::fs::read(dest).unwrap(),
2235 b"audio bytes for Airbag".to_vec()
2236 );
2237 assert_eq!(
2238 db_path_of(&db, first_id).as_deref(),
2239 Some(dest.to_str().unwrap())
2240 );
2241 }
2242
2243 #[test]
2244 fn existing_destination_is_never_overwritten() {
2245 let db = test_db();
2246 let tmp = TempDir::new().unwrap();
2247 let source = tmp.path().join("src/new.flac");
2248 add_track(&db, &source, "Airbag", 1);
2249
2250 let dest = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
2252 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
2253 std::fs::write(&dest, b"the good rip").unwrap();
2254
2255 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2256 assert_eq!(result.moved_count(), 0);
2257
2258 let blocked = result.conflicts().next().unwrap();
2261 assert_eq!(result.conflicts().count(), 1);
2262 assert_eq!(blocked.from, source);
2263 assert_eq!(blocked.dest(), dest);
2264 assert!(blocked.outcome.reason().unwrap().contains("overwritten"));
2265
2266 assert_eq!(std::fs::read(&dest).unwrap(), b"the good rip".to_vec());
2267 assert!(source.exists());
2268 }
2269
2270 #[test]
2273 fn move_file_refuses_an_occupied_destination() {
2274 let tmp = TempDir::new().unwrap();
2275 let from = tmp.path().join("a.flac");
2276 let to = tmp.path().join("b.flac");
2277 std::fs::write(&from, b"source").unwrap();
2278 std::fs::write(&to, b"keep me").unwrap();
2279
2280 let err = move_file(&from, &to).unwrap_err();
2281 assert!(matches!(err, OrganizeError::DestinationExists(_)));
2282 assert_eq!(std::fs::read(&to).unwrap(), b"keep me".to_vec());
2283 assert_eq!(std::fs::read(&from).unwrap(), b"source".to_vec());
2284 }
2285
2286 #[cfg(any(target_os = "macos", target_os = "ios"))]
2287 #[test]
2288 fn case_only_difference_collides_on_a_case_insensitive_filesystem() {
2289 let db = test_db();
2290 let tmp = TempDir::new().unwrap();
2291 let first = tmp.path().join("src/1.flac");
2292 let second = tmp.path().join("src/2.flac");
2293 std::fs::create_dir_all(first.parent().unwrap()).unwrap();
2294 for (path, title, number) in [(&first, "Rain", 1i32), (&second, "RAIN", 2)] {
2295 std::fs::write(path, format!("audio bytes for {title}")).unwrap();
2296 let mut meta = sample_meta(title, "Radiohead", "OK Computer");
2297 meta.track_number = Some(number);
2298 meta.path = Some(path.to_string_lossy().into_owned());
2299 queries::upsert_track(&db.conn, &meta).unwrap();
2300 }
2301
2302 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2303 assert_eq!(result.moved_count(), 1);
2304 assert_eq!(result.failures().count(), 1);
2305 assert!(second.exists());
2306 assert_eq!(
2307 std::fs::read(&second).unwrap(),
2308 b"audio bytes for RAIN".to_vec()
2309 );
2310 }
2311
2312 #[cfg(not(target_os = "ios"))]
2320 #[test]
2321 fn case_only_rename_keeps_the_file() {
2322 let tmp = TempDir::new().unwrap();
2323 let from = tmp.path().join("rain.flac");
2324 let to = tmp.path().join("Rain.flac");
2325 std::fs::write(&from, b"audio bytes").unwrap();
2326
2327 move_file(&from, &to).unwrap();
2328
2329 assert_eq!(std::fs::read(&to).unwrap(), b"audio bytes".to_vec());
2330 let names: Vec<String> = std::fs::read_dir(tmp.path())
2331 .unwrap()
2332 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
2333 .collect();
2334 assert_eq!(names, vec!["Rain.flac".to_string()]);
2335 }
2336
2337 #[test]
2340 fn cross_device_copy_verifies_before_dropping_the_source() {
2341 let tmp = TempDir::new().unwrap();
2342 let from = tmp.path().join("a.flac");
2343 let to = tmp.path().join("b.flac");
2344 let bytes: Vec<u8> = (0..64_000u32).map(|i| (i % 251) as u8).collect();
2345 std::fs::write(&from, &bytes).unwrap();
2346 let mtime = std::fs::metadata(&from).unwrap().modified().unwrap();
2347
2348 copy_across_devices(&from, &to).unwrap();
2349
2350 assert!(!from.exists());
2351 assert_eq!(std::fs::read(&to).unwrap(), bytes);
2352 assert_eq!(std::fs::metadata(&to).unwrap().modified().unwrap(), mtime);
2354 let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
2356 .unwrap()
2357 .filter(|e| {
2358 e.as_ref()
2359 .unwrap()
2360 .file_name()
2361 .to_string_lossy()
2362 .starts_with(".koan-")
2363 })
2364 .collect();
2365 assert!(leftovers.is_empty());
2366 }
2367
2368 #[test]
2369 fn free_space_check_ignores_same_device_moves() {
2370 let tmp = TempDir::new().unwrap();
2371 let from = tmp.path().join("a.flac");
2372 std::fs::write(&from, b"audio").unwrap();
2373 let moves = vec![FileMove {
2374 track_id: None,
2375 from,
2376 to: tmp.path().join("b.flac"),
2377 ancillary: Vec::new(),
2378 }];
2379 assert!(check_free_space(&moves, tmp.path()).is_ok());
2381 }
2382
2383 #[test]
2386 fn unknown_function_refuses_the_move() {
2387 let db = test_db();
2388 let tmp = TempDir::new().unwrap();
2389 let source = tmp.path().join("src/test.flac");
2390 add_track(&db, &source, "Airbag", 1);
2391
2392 let result = execute(
2394 &db,
2395 "%album artist%/%album%/$nun(%tracknumber%,2). %title%",
2396 Some(tmp.path()),
2397 )
2398 .unwrap();
2399 assert_eq!(result.moved_count(), 0);
2400 assert_eq!(result.failures().count(), 1);
2401 assert!(result.failure_messages()[0].contains("unknown function"));
2402 assert!(source.exists());
2403 }
2404
2405 #[test]
2408 fn empty_final_component_refuses_the_move() {
2409 let db = test_db();
2410 let tmp = TempDir::new().unwrap();
2411 let first = tmp.path().join("src/a.flac");
2412 let second = tmp.path().join("src/b.flac");
2413 add_track(&db, &first, "Airbag", 1);
2414 add_track(&db, &second, "Karma Police", 2);
2415
2416 let result = execute(
2418 &db,
2419 "%album artist%/%album%/[%nonexistent field%]",
2420 Some(tmp.path()),
2421 )
2422 .unwrap();
2423
2424 assert_eq!(result.moved_count(), 0);
2425 assert_eq!(result.failures().count(), 2);
2426 assert!(first.exists());
2427 assert!(second.exists());
2428 assert!(!tmp.path().join("Radiohead/OK Computer.flac").exists());
2429 }
2430
2431 #[test]
2432 fn long_title_is_truncated_rather_than_failing() {
2433 let db = test_db();
2434 let tmp = TempDir::new().unwrap();
2435 let source = tmp.path().join("src/test.flac");
2436 let title = "a".repeat(300);
2437 add_track(&db, &source, &title, 1);
2438
2439 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2440 assert_eq!(
2441 result.moved_count(),
2442 1,
2443 "errors: {:?}",
2444 result.failure_messages()
2445 );
2446 let name = result
2447 .moves()
2448 .next()
2449 .unwrap()
2450 .dest()
2451 .file_name()
2452 .unwrap()
2453 .to_string_lossy();
2454 assert!(name.len() <= MAX_FILE_NAME_BYTES);
2455 assert!(name.ends_with(".flac"));
2456 assert!(result.moves().next().unwrap().dest().exists());
2457 }
2458
2459 #[test]
2462 fn remove_empty_dirs_never_climbs_past_a_floor() {
2463 let tmp = TempDir::new().unwrap();
2464 let root = tmp.path().join("library");
2465 let nested = root.join("artist/album");
2466 std::fs::create_dir_all(&nested).unwrap();
2467
2468 remove_empty_dirs(&nested, std::slice::from_ref(&root));
2469
2470 assert!(!nested.exists());
2471 assert!(!root.join("artist").exists());
2472 assert!(root.exists(), "the library root must survive");
2473 }
2474
2475 #[test]
2476 fn remove_empty_dirs_stays_put_outside_any_floor() {
2477 let tmp = TempDir::new().unwrap();
2478 let outside = tmp.path().join("incoming/rip");
2479 std::fs::create_dir_all(&outside).unwrap();
2480
2481 remove_empty_dirs(&outside, &[tmp.path().join("library")]);
2482
2483 assert!(!outside.exists());
2484 assert!(
2485 tmp.path().join("incoming").exists(),
2486 "no floor means no climbing"
2487 );
2488 }
2489
2490 #[test]
2491 fn remove_empty_dirs_never_removes_a_floor_itself() {
2492 let tmp = TempDir::new().unwrap();
2493 let root = tmp.path().join("library");
2494 std::fs::create_dir_all(&root).unwrap();
2495
2496 remove_empty_dirs(&root, std::slice::from_ref(&root));
2497
2498 assert!(root.exists());
2499 }
2500
2501 #[test]
2504 fn undo_refuses_when_the_original_path_is_occupied() {
2505 let db = test_db();
2506 let tmp = TempDir::new().unwrap();
2507 let source = tmp.path().join("src/test.flac");
2508 add_track(&db, &source, "Airbag", 1);
2509
2510 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2511 let dest = result.moves().next().unwrap().dest().to_path_buf();
2512
2513 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2515 std::fs::write(&source, b"a completely different rip").unwrap();
2516
2517 let undone = undo(&db).unwrap();
2518 assert_eq!(undone.restored, 0);
2519 assert_eq!(undone.errors.len(), 1);
2520 assert_eq!(
2521 std::fs::read(&source).unwrap(),
2522 b"a completely different rip".to_vec()
2523 );
2524 assert!(dest.exists());
2525 assert_eq!(log_rows(&db).len(), 1);
2527 }
2528
2529 #[test]
2530 fn undo_refuses_when_the_moved_file_has_been_replaced() {
2531 let db = test_db();
2532 let tmp = TempDir::new().unwrap();
2533 let source = tmp.path().join("src/test.flac");
2534 add_track(&db, &source, "Airbag", 1);
2535
2536 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2537 let dest = result.moves().next().unwrap().dest().to_path_buf();
2538 std::fs::write(&dest, b"replaced with something else entirely").unwrap();
2539
2540 let undone = undo(&db).unwrap();
2541 assert_eq!(undone.restored, 0);
2542 assert_eq!(undone.errors.len(), 1);
2543 assert!(!source.exists());
2544 assert!(dest.exists());
2545 }
2546
2547 #[test]
2550 fn undo_keeps_moves_onto_a_volume_that_is_away() {
2551 let db = test_db();
2552 let tmp = TempDir::new().unwrap();
2553 let source = tmp.path().join("src/test.flac");
2554 add_track(&db, &source, "Airbag", 1);
2555 let volume = tmp.path().join("volume");
2556 std::fs::create_dir_all(&volume).unwrap();
2557
2558 execute(&db, "%album artist%/%album%/%title%", Some(&volume)).unwrap();
2559 let away = tmp.path().join("away");
2560 std::fs::rename(&volume, &away).unwrap();
2561
2562 let floors = [volume.clone()];
2563 let undone = undo_within(&db, &floors).unwrap();
2564 assert_eq!((undone.restored, undone.errors.len()), (0, 1));
2565 assert_eq!(log_rows(&db).len(), 1, "kept for when the volume is back");
2566
2567 std::fs::rename(&away, &volume).unwrap();
2568 assert_eq!(undo_within(&db, &floors).unwrap().restored, 1);
2569 assert!(source.exists());
2570 }
2571
2572 #[test]
2575 fn undo_forgets_an_album_deleted_by_hand() {
2576 let db = test_db();
2577 let tmp = TempDir::new().unwrap();
2578 let library = tmp.path().join("library");
2579 let older = tmp.path().join("src/older.flac");
2580 add_track(&db, &older, "Airbag", 1);
2581 std::fs::create_dir_all(library.join("keep")).unwrap();
2582 execute(&db, "%album artist%/%album%/%title%", Some(&library)).unwrap();
2583
2584 let newer = tmp.path().join("src/newer.flac");
2585 let mut meta = sample_meta("Lucky", "Radiohead", "OK Computer");
2586 meta.path = Some(newer.to_string_lossy().into_owned());
2587 std::fs::create_dir_all(newer.parent().unwrap()).unwrap();
2589 std::fs::write(&newer, b"lucky").unwrap();
2590 queries::upsert_track(&db.conn, &meta).unwrap();
2591 let result = run(
2592 &db,
2593 Selection::Paths(std::slice::from_ref(&newer)),
2594 "%album artist%/Elsewhere/%title%",
2595 &library,
2596 )
2597 .unwrap();
2598 let dest = result.moves().next().unwrap().dest().to_path_buf();
2599 std::fs::remove_dir_all(dest.parent().unwrap()).unwrap();
2600
2601 let floors = [library.clone()];
2602 let undone = undo_within(&db, &floors).unwrap();
2603 assert_eq!((undone.restored, undone.errors.len()), (0, 0));
2604 assert_eq!(undo_within(&db, &floors).unwrap().restored, 1);
2605 assert!(older.exists());
2606 }
2607
2608 #[test]
2612 fn undoing_an_interrupted_run_leaves_its_lists_intact() {
2613 let db = test_db();
2614 let tmp = TempDir::new().unwrap();
2615 let album = tmp.path().join("src/Album");
2616 add_track(&db, &album.join("01.flac"), "Airbag", 1);
2617 add_track(&db, &album.join("02.flac"), "Paranoid Android", 2);
2618 let list = album.join("album.m3u");
2619 std::fs::write(&list, "01.flac\n02.flac\n").unwrap();
2620
2621 let base = tmp.path().join("library");
2623 let planned = plan(
2624 &db,
2625 Selection::All,
2626 "%album artist%/%album%/%title%",
2627 &base,
2628 true,
2629 )
2630 .unwrap();
2631 let first = planned
2632 .entries
2633 .iter()
2634 .filter_map(PlanEntry::as_move)
2635 .find(|m| m.ancillary.iter().any(|(from, _)| from == &list))
2636 .expect("the list travels with one of the moves");
2637 execute_single_move(
2638 &db,
2639 &first,
2640 "batch-interrupted",
2641 std::slice::from_ref(&base),
2642 )
2643 .unwrap();
2644
2645 let undone = undo_within(&db, &[base]).unwrap();
2646 assert!(undone.errors.is_empty(), "{:?}", undone.errors);
2647 assert_eq!(
2648 std::fs::read_to_string(&list).unwrap(),
2649 "01.flac\n02.flac\n"
2650 );
2651 }
2652
2653 #[test]
2657 fn playlist_files_follow_what_organize_moves() {
2658 use crate::index::playlist_files;
2659 let db = test_db();
2660 let tmp = TempDir::new().unwrap();
2661 let album = tmp.path().join("src/Album");
2662 let first = album.join("01.flac");
2663 let second = album.join("02.flac");
2664 let a = add_track(&db, &first, "Airbag", 1);
2665 let b = add_track(&db, &second, "Paranoid Android", 2);
2666 let beside = album.join("album.m3u");
2667 std::fs::write(&beside, "#EXTM3U\n01.flac\n02.flac\n").unwrap();
2668 let elsewhere = tmp.path().join("lists/mix.m3u8");
2669 std::fs::create_dir_all(elsewhere.parent().unwrap()).unwrap();
2670 std::fs::write(&elsewhere, format!("{}\n", second.display())).unwrap();
2671 playlist_files::import(&db, &[beside.clone(), elsewhere.clone()], &[]);
2672
2673 let lists = |db: &Database| -> Vec<(i64, String, String, Vec<i64>)> {
2674 queries::list_playlists(&db.conn, queries::LOCAL_USER)
2675 .unwrap()
2676 .into_iter()
2677 .map(|p| {
2678 let tracks = queries::playlist_track_ids(&db.conn, p.id).unwrap();
2679 (p.id, p.uid, p.source_path.unwrap(), tracks)
2680 })
2681 .collect()
2682 };
2683 let before = lists(&db);
2684 assert_eq!(before.len(), 2);
2685
2686 let base = tmp.path().join("library");
2687 execute(&db, "%album artist%/%album%/%title%", Some(&base)).unwrap();
2688 let moved_list = base.join("Radiohead/OK Computer/album.m3u");
2689 assert_eq!(
2690 std::fs::read_to_string(&moved_list).unwrap(),
2691 "#EXTM3U\nAirbag.flac\nParanoid Android.flac\n"
2692 );
2693
2694 playlist_files::import(
2696 &db,
2697 &[moved_list.clone(), elsewhere.clone()],
2698 &[tmp.path().to_path_buf()],
2699 );
2700 let after = lists(&db);
2701 assert_eq!(after.len(), 2, "{after:#?}");
2702 for (was, now) in before.iter().zip(&after) {
2703 assert_eq!((was.0, &was.1, &was.3), (now.0, &now.1, &now.3));
2704 }
2705 assert!(after.iter().any(|l| Path::new(&l.2) == moved_list));
2706 assert!(after.iter().any(|l| l.3 == [a, b]));
2707
2708 let undone = undo(&db).unwrap();
2709 assert!(undone.errors.is_empty(), "{:?}", undone.errors);
2710 assert_eq!(
2711 std::fs::read_to_string(&beside).unwrap(),
2712 "#EXTM3U\n01.flac\n02.flac\n"
2713 );
2714 assert_eq!(
2715 std::fs::read_to_string(&elsewhere).unwrap(),
2716 format!("{}\n", second.display())
2717 );
2718 }
2719
2720 #[test]
2722 fn undo_takes_the_newest_batch_when_timestamps_tie() {
2723 let db = test_db();
2724 let tmp = TempDir::new().unwrap();
2725 let older = tmp.path().join("older.flac");
2726 let newer = tmp.path().join("newer.flac");
2727 std::fs::write(&older, b"older").unwrap();
2728 std::fs::write(&newer, b"newer").unwrap();
2729 let moved_older = tmp.path().join("moved-older.flac");
2730 let moved_newer = tmp.path().join("moved-newer.flac");
2731 std::fs::rename(&older, &moved_older).unwrap();
2732 std::fs::rename(&newer, &moved_newer).unwrap();
2733
2734 for (batch, from, to) in [
2735 ("batch-1", &older, &moved_older),
2736 ("batch-2", &newer, &moved_newer),
2737 ] {
2738 db.conn
2739 .execute(
2740 "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, created_at)
2741 VALUES (?1, NULL, ?2, ?3, '2025-01-01 00:00:00')",
2742 params![
2743 batch,
2744 from.to_string_lossy().as_ref(),
2745 to.to_string_lossy().as_ref()
2746 ],
2747 )
2748 .unwrap();
2749 }
2750
2751 let undone = undo(&db).unwrap();
2752 assert_eq!(undone.restored, 1);
2753 assert!(newer.exists(), "the newest batch is the one undone");
2754 assert!(!older.exists());
2755 }
2756
2757 #[test]
2760 fn favourites_and_queue_state_follow_the_move() {
2761 let db = test_db();
2762 let tmp = TempDir::new().unwrap();
2763 let source = tmp.path().join("src/test.flac");
2764 add_track(&db, &source, "Airbag", 1);
2765 let source_str = source.to_string_lossy().into_owned();
2766
2767 let track = queries::track_id_by_path(&db.conn, &source_str)
2768 .unwrap()
2769 .unwrap();
2770 queries::add_favourite(&db.conn, crate::db::queries::LOCAL_USER, track).unwrap();
2771 let item = PersistedQueueItem {
2772 path: source_str.clone(),
2773 title: "Airbag".into(),
2774 artist: "Radiohead".into(),
2775 album_artist: "Radiohead".into(),
2776 album: "OK Computer".into(),
2777 year: None,
2778 codec: None,
2779 track_number: Some(1),
2780 disc: Some(1),
2781 duration_ms: None,
2782 db_id: None,
2783 played: None,
2784 };
2785 queries::save_playback_state(
2786 &db.conn,
2787 &[item],
2788 Default::default(),
2789 Some(&source_str),
2790 0,
2791 false,
2792 )
2793 .unwrap();
2794
2795 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2796 let dest = result.moves().next().unwrap().dest().to_path_buf();
2797 let dest_str = dest.to_string_lossy().into_owned();
2798
2799 let favourites =
2800 queries::load_favourites(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2801 assert!(favourites.contains(&track));
2802 assert_eq!(
2803 queries::track_id_by_path(&db.conn, &dest_str).unwrap(),
2804 Some(track)
2805 );
2806
2807 let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
2808 assert_eq!(state.items[0].path, dest_str);
2809 assert_eq!(state.cursor_path.as_deref(), Some(dest_str.as_str()));
2810
2811 assert_eq!(undo(&db).unwrap().restored, 1);
2812
2813 let favourites =
2814 queries::load_favourites(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2815 assert!(favourites.contains(&track));
2816 let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
2817 assert_eq!(state.items[0].path, source_str);
2818 assert_eq!(state.cursor_path.as_deref(), Some(source_str.as_str()));
2819 }
2820
2821 #[test]
2822 fn scan_cache_follows_the_move() {
2823 let db = test_db();
2824 let tmp = TempDir::new().unwrap();
2825 let source = tmp.path().join("src/test.flac");
2826 let id = add_track(&db, &source, "Airbag", 1);
2827 db.conn
2828 .execute(
2829 "INSERT INTO scan_cache (path, mtime, size, track_id) VALUES (?1, 1, 1, ?2)",
2830 params![source.to_string_lossy().as_ref(), id],
2831 )
2832 .unwrap();
2833
2834 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2835 let dest = result
2836 .moves()
2837 .next()
2838 .unwrap()
2839 .dest()
2840 .to_string_lossy()
2841 .into_owned();
2842
2843 let cached: String = db
2844 .conn
2845 .query_row(
2846 "SELECT path FROM scan_cache WHERE track_id = ?1",
2847 params![id],
2848 |r| r.get(0),
2849 )
2850 .unwrap();
2851 assert_eq!(cached, dest);
2852 }
2853
2854 #[test]
2857 fn partial_failure_leaves_the_database_and_result_consistent() {
2858 let db = test_db();
2859 let tmp = TempDir::new().unwrap();
2860 let first = tmp.path().join("src/a.flac");
2861 let clash = tmp.path().join("src/b.flac");
2862 let third = tmp.path().join("src/c.flac");
2863 let first_id = add_track(&db, &first, "Airbag", 1);
2864 let clash_id = add_track(&db, &clash, "Airbag", 2);
2865 let third_id = add_track(&db, &third, "Karma Police", 3);
2866
2867 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2868
2869 assert_eq!(result.moved_count(), 2);
2870 assert_eq!(result.failures().count(), 1);
2871
2872 let logged = log_rows(&db);
2873 assert_eq!(logged.len(), 2);
2874 for file_move in result.moves() {
2875 assert!(file_move.dest().exists());
2876 assert!(
2877 logged
2878 .iter()
2879 .any(|(_, _, to)| Path::new(to) == file_move.dest())
2880 );
2881 }
2882
2883 assert!(clash.exists());
2885 assert_eq!(
2886 db_path_of(&db, clash_id).as_deref(),
2887 Some(clash.to_str().unwrap())
2888 );
2889 assert_ne!(db_path_of(&db, first_id).as_deref(), first.to_str());
2890 assert_ne!(db_path_of(&db, third_id).as_deref(), third.to_str());
2891 }
2892
2893 #[test]
2896 fn unknown_paths_are_logged_and_undoable() {
2897 let db = test_db();
2898 let tmp = TempDir::new().unwrap();
2899 let known = tmp.path().join("src/known.flac");
2900 add_track(&db, &known, "Airbag", 1);
2901
2902 let result = run(
2903 &db,
2904 Selection::Paths(std::slice::from_ref(&known)),
2905 "%album artist%/%album%/%title%",
2906 tmp.path(),
2907 )
2908 .unwrap();
2909
2910 assert_eq!(result.moved_count(), 1);
2911 let logged = log_rows(&db);
2912 assert_eq!(logged.len(), 1);
2913 assert!(logged[0].0.is_some());
2914
2915 assert_eq!(undo(&db).unwrap().restored, 1);
2916 assert!(known.exists());
2917 }
2918
2919 #[test]
2920 fn ancillary_files_move_with_the_album() {
2921 let db = test_db();
2922 let tmp = TempDir::new().unwrap();
2923 let source = tmp.path().join("src/test.flac");
2924 add_track(&db, &source, "Airbag", 1);
2925 std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2926
2927 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2928 assert_eq!(result.moved_count(), 1);
2929 let dest_dir = result.moves().next().unwrap().dest().parent().unwrap();
2930 assert!(dest_dir.join("cover.jpg").exists());
2931
2932 assert_eq!(log_rows(&db).len(), 2);
2934 assert_eq!(undo(&db).unwrap().restored, 2);
2935 assert!(source.parent().unwrap().join("cover.jpg").exists());
2936 }
2937
2938 #[test]
2941 fn extension_not_clobbered_by_dots_in_title() {
2942 let db = test_db();
2945 let tmp = TempDir::new().unwrap();
2946 let source = tmp.path().join("src/CHROMA 011 A.L.O.E II.flac");
2947 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2948 std::fs::write(&source, b"fake").unwrap();
2949
2950 let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
2951 meta.track_number = Some(10);
2952 meta.date = Some("2025-11-21".into());
2953 meta.path = Some(source.to_string_lossy().into_owned());
2954 queries::upsert_track(&db.conn, &meta).unwrap();
2955
2956 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2957 let result = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2958 assert_eq!(result.moved_count(), 1);
2959 assert_eq!(
2960 result
2961 .moves()
2962 .next()
2963 .unwrap()
2964 .dest()
2965 .file_name()
2966 .unwrap()
2967 .to_string_lossy(),
2968 "0110. Bicep - CHROMA 011 A.L.O.E II.flac"
2969 );
2970 }
2971
2972 #[test]
2973 fn extension_preserved_for_tracknumber_dot() {
2974 let db = test_db();
2976 let tmp = TempDir::new().unwrap();
2977 let source = tmp.path().join("src/CHROMA 012 TANGZ II.flac");
2978 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2979 std::fs::write(&source, b"fake").unwrap();
2980
2981 let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
2982 meta.track_number = Some(11);
2983 meta.date = Some("2025-11-21".into());
2984 meta.path = Some(source.to_string_lossy().into_owned());
2985 queries::upsert_track(&db.conn, &meta).unwrap();
2986
2987 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2988 let result = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2989 assert_eq!(result.moved_count(), 1);
2990 assert_eq!(
2991 result
2992 .moves()
2993 .next()
2994 .unwrap()
2995 .dest()
2996 .file_name()
2997 .unwrap()
2998 .to_string_lossy(),
2999 "0111. Bicep - CHROMA 012 TANGZ II.flac"
3000 );
3001 }
3002}