1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use rusqlite::params;
5
6use crate::db::connection::{Database, DbError};
7use crate::db::queries::{self, TrackRow};
8use crate::format::{self, FormatError, MetadataProvider};
9
10const ANCILLARY_PATTERNS: &[&str] = &[
12 "cover.jpg",
13 "cover.png",
14 "cover.webp",
15 "folder.jpg",
16 "folder.png",
17 "front.jpg",
18 "front.png",
19];
20
21const ANCILLARY_EXTENSIONS: &[&str] = &["cue", "log", "m3u", "m3u8"];
22
23#[derive(Debug, thiserror::Error)]
24pub enum OrganizeError {
25 #[error("database error: {0}")]
26 Db(#[from] DbError),
27 #[error("sqlite error: {0}")]
28 Sqlite(#[from] rusqlite::Error),
29 #[error("format error: {0}")]
30 Format(#[from] FormatError),
31 #[error("io error: {0}")]
32 Io(#[from] std::io::Error),
33 #[error("no tracks with local paths found")]
34 NoLocalTracks,
35 #[error("no organize batches to undo")]
36 NothingToUndo,
37}
38
39#[derive(Debug)]
40pub struct OrganizeResult {
41 pub moves: Vec<FileMove>,
42 pub errors: Vec<(PathBuf, String)>,
43 pub skipped: usize,
44}
45
46#[derive(Debug)]
47pub struct FileMove {
48 pub track_id: i64,
49 pub from: PathBuf,
50 pub to: PathBuf,
51 pub ancillary: Vec<(PathBuf, PathBuf)>,
52}
53
54struct TrackMetadata {
56 fields: HashMap<String, String>,
57}
58
59impl TrackMetadata {
60 fn from_track_row(track: &TrackRow, album_date: Option<&str>) -> Self {
61 let mut fields = HashMap::new();
62 let s = sanitize_path_component;
64 fields.insert("title".into(), s(&track.title));
65 fields.insert("artist".into(), s(&track.artist_name));
66 fields.insert("album artist".into(), s(&track.album_artist_name));
67 fields.insert("album".into(), s(&track.album_title));
68 if let Some(n) = track.track_number {
69 fields.insert("tracknumber".into(), format!("{n:02}"));
70 }
71 if let Some(d) = track.disc {
72 fields.insert("discnumber".into(), d.to_string());
73 }
74 if let Some(date) = album_date {
75 fields.insert("date".into(), date.to_string());
77 }
78 if let Some(ref codec) = track.codec {
79 fields.insert("codec".into(), codec.clone());
80 }
81 if let Some(ref genre) = track.genre {
82 fields.insert("genre".into(), s(genre));
83 }
84 Self { fields }
85 }
86
87 fn from_file_meta(meta: &queries::TrackMeta) -> Self {
89 let mut fields = HashMap::new();
90 let s = sanitize_path_component;
91 fields.insert("title".into(), s(&meta.title));
92 fields.insert("artist".into(), s(&meta.artist));
93 fields.insert(
94 "album artist".into(),
95 s(meta.album_artist.as_deref().unwrap_or(&meta.artist)),
96 );
97 fields.insert("album".into(), s(&meta.album));
98 if let Some(n) = meta.track_number {
99 fields.insert("tracknumber".into(), format!("{n:02}"));
100 }
101 if let Some(d) = meta.disc {
102 fields.insert("discnumber".into(), d.to_string());
103 }
104 if let Some(ref date) = meta.date {
105 fields.insert("date".into(), date.clone());
106 }
107 if let Some(ref codec) = meta.codec {
108 fields.insert("codec".into(), codec.clone());
109 }
110 if let Some(ref genre) = meta.genre {
111 fields.insert("genre".into(), s(genre));
112 }
113 if let Some(ref label) = meta.label {
114 fields.insert("label".into(), s(label));
115 }
116 Self { fields }
117 }
118}
119
120impl MetadataProvider for TrackMetadata {
121 fn get_field(&self, name: &str) -> Option<String> {
122 self.fields.get(name).cloned()
123 }
124}
125
126fn sanitize_path_component(s: &str) -> String {
128 s.chars()
129 .map(|c| match c {
130 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
131 _ => c,
132 })
133 .collect::<String>()
134 .trim()
135 .to_string()
136}
137
138fn sanitize_relative_path(rel: &str) -> PathBuf {
142 let parts: Vec<&str> = if rel.contains('/') {
143 rel.split('/')
144 } else {
145 rel.split(std::path::MAIN_SEPARATOR)
146 }
147 .collect();
148
149 let mut result = PathBuf::new();
150 for part in parts {
151 let sanitized = sanitize_path_component(part);
152 if sanitized.is_empty() || sanitized == "." || sanitized == ".." {
153 continue;
154 }
155 result.push(sanitized);
156 }
157 result
158}
159
160fn album_date_for_track(
162 db: &Database,
163 track: &TrackRow,
164) -> Result<Option<String>, rusqlite::Error> {
165 let Some(album_id) = track.album_id else {
166 return Ok(None);
167 };
168 db.conn
169 .query_row(
170 "SELECT date FROM albums WHERE id = ?1",
171 params![album_id],
172 |row| row.get::<_, Option<String>>(0),
173 )
174 .or(Ok(None))
175}
176
177fn find_ancillary_files(track_dir: &Path) -> Vec<PathBuf> {
179 let mut files = Vec::new();
180 let Ok(entries) = std::fs::read_dir(track_dir) else {
181 return files;
182 };
183
184 for entry in entries.flatten() {
185 let path = entry.path();
186 if !path.is_file() {
187 continue;
188 }
189 let name = path
190 .file_name()
191 .and_then(|n| n.to_str())
192 .unwrap_or_default()
193 .to_lowercase();
194
195 if ANCILLARY_PATTERNS.iter().any(|p| name == *p) {
197 files.push(path);
198 continue;
199 }
200 if let Some(ext) = path.extension().and_then(|e| e.to_str())
202 && ANCILLARY_EXTENSIONS
203 .iter()
204 .any(|e| ext.eq_ignore_ascii_case(e))
205 {
206 files.push(path);
207 }
208 }
209 files
210}
211
212fn plan_single_move(
217 source: &Path,
218 track_id: i64,
219 metadata: &TrackMetadata,
220 pattern: &str,
221 base_dir: &Path,
222 planned_ancillary: &mut std::collections::HashSet<PathBuf>,
223) -> Result<Option<FileMove>, String> {
224 let relative = format::format(pattern, metadata).map_err(|e| format!("format error: {e}"))?;
225
226 if relative.is_empty() {
227 return Err("format string produced empty path".into());
228 }
229
230 let sanitized = sanitize_relative_path(&relative);
231
232 if sanitized.as_os_str().is_empty() {
233 return Err("format string produced empty path after sanitization".into());
234 }
235
236 let ext = source
240 .extension()
241 .and_then(|e| e.to_str())
242 .unwrap_or("flac");
243 let mut dest = base_dir.join(sanitized);
244 let stem = dest.as_os_str().to_os_string();
245 dest = PathBuf::from(format!("{}.{}", stem.to_string_lossy(), ext));
246
247 if !dest.starts_with(base_dir) {
249 return Err(format!(
250 "path traversal blocked: destination {} escapes base dir {}",
251 dest.display(),
252 base_dir.display()
253 ));
254 }
255
256 if paths_equal(source, &dest) {
257 return Ok(None);
258 }
259
260 let source_dir = source.parent().unwrap_or(Path::new("."));
262 let dest_dir = dest.parent().unwrap_or(Path::new("."));
263 let mut ancillary = Vec::new();
264
265 if source_dir != dest_dir {
266 for anc_path in find_ancillary_files(source_dir) {
267 if planned_ancillary.contains(&anc_path) {
268 continue;
269 }
270 let Some(anc_name) = anc_path.file_name() else {
271 continue;
272 };
273 let anc_dest = dest_dir.join(anc_name);
274 planned_ancillary.insert(anc_path.clone());
275 ancillary.push((anc_path, anc_dest));
276 }
277 }
278
279 Ok(Some(FileMove {
280 track_id,
281 from: source.to_path_buf(),
282 to: dest,
283 ancillary,
284 }))
285}
286
287fn plan_moves(
289 db: &Database,
290 pattern: &str,
291 base_dir: &Path,
292 track_ids: Option<&[i64]>,
293) -> Result<OrganizeResult, OrganizeError> {
294 let tracks = match track_ids {
295 Some(ids) => {
296 let mut tracks = Vec::with_capacity(ids.len());
297 for &id in ids {
298 if let Some(row) = queries::get_track_row(&db.conn, id)? {
299 tracks.push(row);
300 }
301 }
302 tracks
303 }
304 None => queries::all_tracks(&db.conn)?,
305 };
306 let mut moves = Vec::new();
307 let mut errors = Vec::new();
308 let mut skipped = 0;
309
310 let mut planned_ancillary: std::collections::HashSet<PathBuf> =
312 std::collections::HashSet::new();
313
314 for track in &tracks {
315 let Some(ref path_str) = track.path else {
316 continue; };
318
319 let source = PathBuf::from(path_str);
320 if !source.exists() {
321 continue; }
323
324 let album_date = match album_date_for_track(db, track) {
325 Ok(d) => d,
326 Err(e) => {
327 errors.push((source, format!("failed to get album date: {e}")));
328 continue;
329 }
330 };
331
332 let metadata = TrackMetadata::from_track_row(track, album_date.as_deref());
333
334 match plan_single_move(
335 &source,
336 track.id,
337 &metadata,
338 pattern,
339 base_dir,
340 &mut planned_ancillary,
341 ) {
342 Ok(Some(file_move)) => moves.push(file_move),
343 Ok(None) => skipped += 1,
344 Err(msg) => errors.push((source, msg)),
345 }
346 }
347
348 Ok(OrganizeResult {
349 moves,
350 errors,
351 skipped,
352 })
353}
354
355fn plan_moves_from_paths(
359 paths: &[PathBuf],
360 pattern: &str,
361 base_dir: &Path,
362) -> Result<OrganizeResult, OrganizeError> {
363 use rayon::prelude::*;
364
365 let meta_results: Vec<_> = paths
367 .par_iter()
368 .map(|source| {
369 if !source.exists() {
370 return (source.clone(), Err("file not found".to_string()));
371 }
372 match crate::index::metadata::read_metadata(source) {
373 Ok(m) => (source.clone(), Ok(TrackMetadata::from_file_meta(&m))),
374 Err(e) => (source.clone(), Err(format!("metadata error: {e}"))),
375 }
376 })
377 .collect();
378
379 let mut moves = Vec::new();
381 let mut errors = Vec::new();
382 let mut skipped = 0;
383 let mut planned_ancillary: std::collections::HashSet<PathBuf> =
384 std::collections::HashSet::new();
385
386 for (source, meta_result) in meta_results {
387 let metadata = match meta_result {
388 Ok(m) => m,
389 Err(msg) => {
390 errors.push((source, msg));
391 continue;
392 }
393 };
394
395 match plan_single_move(
396 &source,
397 0,
398 &metadata,
399 pattern,
400 base_dir,
401 &mut planned_ancillary,
402 ) {
403 Ok(Some(file_move)) => moves.push(file_move),
404 Ok(None) => skipped += 1,
405 Err(msg) => errors.push((source, msg)),
406 }
407 }
408
409 Ok(OrganizeResult {
410 moves,
411 errors,
412 skipped,
413 })
414}
415
416pub fn preview_for_paths(
419 paths: &[PathBuf],
420 pattern: &str,
421 base_dir: Option<&Path>,
422) -> Result<OrganizeResult, OrganizeError> {
423 use rayon::prelude::*;
424
425 let base = resolve_base_dir(base_dir)?;
426
427 let db_cache: std::collections::HashMap<String, TrackRow> = Database::open_default()
429 .ok()
430 .and_then(|db| queries::all_tracks_by_path(&db.conn).ok())
431 .unwrap_or_default();
432
433 let album_dates: std::collections::HashMap<i64, Option<String>> = if !db_cache.is_empty() {
435 let db = Database::open_default().ok();
436 db.map(|db| {
437 let mut dates = std::collections::HashMap::new();
438 for track in db_cache.values() {
439 if let Some(album_id) = track.album_id {
440 dates
441 .entry(album_id)
442 .or_insert_with(|| queries::album_date(&db.conn, album_id).ok().flatten());
443 }
444 }
445 dates
446 })
447 .unwrap_or_default()
448 } else {
449 std::collections::HashMap::new()
450 };
451
452 let meta_results: Vec<_> = paths
454 .par_iter()
455 .map(|source| {
456 let path_str = source.to_string_lossy();
457
458 if let Some(track) = db_cache.get(path_str.as_ref()) {
460 let album_date = track
461 .album_id
462 .and_then(|aid| album_dates.get(&aid).and_then(|d| d.as_deref()));
463 return (
464 source.clone(),
465 track.id,
466 Ok(TrackMetadata::from_track_row(track, album_date)),
467 );
468 }
469
470 if !source.exists() {
472 return (source.clone(), 0, Err("file not found".to_string()));
473 }
474 match crate::index::metadata::read_metadata(source) {
475 Ok(m) => (source.clone(), 0, Ok(TrackMetadata::from_file_meta(&m))),
476 Err(e) => (source.clone(), 0, Err(format!("metadata error: {e}"))),
477 }
478 })
479 .collect();
480
481 let mut moves = Vec::new();
483 let mut errors = Vec::new();
484 let mut skipped = 0;
485 let mut planned_ancillary: std::collections::HashSet<PathBuf> =
486 std::collections::HashSet::new();
487
488 for (source, track_id, meta_result) in meta_results {
489 let metadata = match meta_result {
490 Ok(m) => m,
491 Err(msg) => {
492 errors.push((source, msg));
493 continue;
494 }
495 };
496
497 match plan_single_move(
498 &source,
499 track_id,
500 &metadata,
501 pattern,
502 &base,
503 &mut planned_ancillary,
504 ) {
505 Ok(Some(file_move)) => moves.push(file_move),
506 Ok(None) => skipped += 1,
507 Err(msg) => errors.push((source, msg)),
508 }
509 }
510
511 Ok(OrganizeResult {
512 moves,
513 errors,
514 skipped,
515 })
516}
517
518pub fn execute_for_paths(
521 paths: &[PathBuf],
522 pattern: &str,
523 base_dir: Option<&Path>,
524) -> Result<OrganizeResult, OrganizeError> {
525 let base = resolve_base_dir(base_dir)?;
526 let mut result = plan_moves_from_paths(paths, pattern, &base)?;
527
528 if result.moves.is_empty() {
529 return Ok(result);
530 }
531
532 let mut completed_moves = Vec::new();
533 let mut new_errors = Vec::new();
534
535 for file_move in result.moves.drain(..) {
536 match execute_single_move_no_db(&file_move) {
537 Ok(()) => match verify_move(&file_move) {
538 Ok(()) => completed_moves.push(file_move),
539 Err(msg) => new_errors.push((file_move.from, msg)),
540 },
541 Err(e) => {
542 new_errors.push((file_move.from, e.to_string()));
543 }
544 }
545 }
546
547 result.moves = completed_moves;
548 result.errors.extend(new_errors);
549 Ok(result)
550}
551
552fn verify_move(file_move: &FileMove) -> Result<(), String> {
554 if !file_move.to.exists() {
555 return Err(format!(
556 "destination not found after move: {}",
557 file_move.to.display()
558 ));
559 }
560 if file_move.from.exists() && !paths_equal(&file_move.from, &file_move.to) {
561 return Err(format!(
562 "source still exists after move: {}",
563 file_move.from.display()
564 ));
565 }
566 Ok(())
567}
568
569fn execute_single_move_no_db(file_move: &FileMove) -> Result<(), OrganizeError> {
571 if let Some(parent) = file_move.to.parent() {
572 std::fs::create_dir_all(parent)?;
573 }
574
575 move_file(&file_move.from, &file_move.to)?;
576
577 for (anc_from, anc_to) in &file_move.ancillary {
579 if let Some(parent) = anc_to.parent() {
580 std::fs::create_dir_all(parent).ok();
581 }
582 if let Err(e) = move_file(anc_from, anc_to) {
583 log::warn!(
584 "failed to move ancillary file {}: {}",
585 anc_from.display(),
586 e
587 );
588 }
589 }
590
591 if let Some(source_dir) = file_move.from.parent() {
592 remove_empty_dirs(source_dir);
593 }
594
595 Ok(())
596}
597
598pub fn preview(
600 db: &Database,
601 pattern: &str,
602 base_dir: Option<&Path>,
603) -> Result<OrganizeResult, OrganizeError> {
604 let base = resolve_base_dir(base_dir)?;
605 plan_moves(db, pattern, &base, None)
606}
607
608pub fn execute(
610 db: &Database,
611 pattern: &str,
612 base_dir: Option<&Path>,
613) -> Result<OrganizeResult, OrganizeError> {
614 let base = resolve_base_dir(base_dir)?;
615 let mut result = plan_moves(db, pattern, &base, None)?;
616
617 if result.moves.is_empty() {
618 return Ok(result);
619 }
620
621 let batch_id = chrono_batch_id();
623
624 let mut completed_moves = Vec::new();
625 let mut new_errors = Vec::new();
626
627 for file_move in result.moves.drain(..) {
628 match execute_single_move(db, &file_move, &batch_id) {
629 Ok(()) => match verify_move(&file_move) {
630 Ok(()) => completed_moves.push(file_move),
631 Err(msg) => new_errors.push((file_move.from, msg)),
632 },
633 Err(e) => {
634 new_errors.push((file_move.from, e.to_string()));
635 }
636 }
637 }
638
639 result.moves = completed_moves;
640 result.errors.extend(new_errors);
641 Ok(result)
642}
643
644pub fn preview_for_tracks(
646 db: &Database,
647 track_ids: &[i64],
648 pattern: &str,
649 base_dir: Option<&Path>,
650) -> Result<OrganizeResult, OrganizeError> {
651 let base = resolve_base_dir(base_dir)?;
652 plan_moves(db, pattern, &base, Some(track_ids))
653}
654
655pub fn execute_for_tracks(
657 db: &Database,
658 track_ids: &[i64],
659 pattern: &str,
660 base_dir: Option<&Path>,
661) -> Result<OrganizeResult, OrganizeError> {
662 let base = resolve_base_dir(base_dir)?;
663 let mut result = plan_moves(db, pattern, &base, Some(track_ids))?;
664
665 if result.moves.is_empty() {
666 return Ok(result);
667 }
668
669 let batch_id = chrono_batch_id();
670 let mut completed_moves = Vec::new();
671 let mut new_errors = Vec::new();
672
673 for file_move in result.moves.drain(..) {
674 match execute_single_move(db, &file_move, &batch_id) {
675 Ok(()) => match verify_move(&file_move) {
676 Ok(()) => completed_moves.push(file_move),
677 Err(msg) => new_errors.push((file_move.from, msg)),
678 },
679 Err(e) => {
680 new_errors.push((file_move.from, e.to_string()));
681 }
682 }
683 }
684
685 result.moves = completed_moves;
686 result.errors.extend(new_errors);
687 Ok(result)
688}
689
690fn execute_single_move(
692 db: &Database,
693 file_move: &FileMove,
694 batch_id: &str,
695) -> Result<(), OrganizeError> {
696 if let Some(parent) = file_move.to.parent() {
698 std::fs::create_dir_all(parent)?;
699 }
700
701 move_file(&file_move.from, &file_move.to)?;
703
704 db.conn.execute(
706 "INSERT INTO organize_log (batch_id, track_id, from_path, to_path) VALUES (?1, ?2, ?3, ?4)",
707 params![
708 batch_id,
709 file_move.track_id,
710 file_move.from.to_string_lossy().as_ref(),
711 file_move.to.to_string_lossy().as_ref(),
712 ],
713 )?;
714
715 db.conn.execute(
717 "UPDATE tracks SET path = ?1 WHERE id = ?2",
718 params![file_move.to.to_string_lossy().as_ref(), file_move.track_id],
719 )?;
720
721 db.conn.execute(
723 "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
724 params![
725 file_move.to.to_string_lossy().as_ref(),
726 file_move.from.to_string_lossy().as_ref(),
727 ],
728 )?;
729
730 for (anc_from, anc_to) in &file_move.ancillary {
732 if let Some(parent) = anc_to.parent() {
733 std::fs::create_dir_all(parent)?;
734 }
735 if move_file(anc_from, anc_to).is_ok() {
737 db.conn.execute(
738 "INSERT INTO organize_log (batch_id, track_id, from_path, to_path) VALUES (?1, NULL, ?2, ?3)",
739 params![
740 batch_id,
741 anc_from.to_string_lossy().as_ref(),
742 anc_to.to_string_lossy().as_ref(),
743 ],
744 )?;
745 }
746 }
747
748 if let Some(source_dir) = file_move.from.parent() {
750 remove_empty_dirs(source_dir);
751 }
752
753 Ok(())
754}
755
756pub fn undo(db: &Database) -> Result<usize, OrganizeError> {
758 let batch_id: String = db
760 .conn
761 .query_row(
762 "SELECT batch_id FROM organize_log ORDER BY created_at DESC LIMIT 1",
763 [],
764 |row| row.get(0),
765 )
766 .map_err(|_| OrganizeError::NothingToUndo)?;
767
768 let mut stmt = db.conn.prepare(
770 "SELECT id, track_id, from_path, to_path FROM organize_log
771 WHERE batch_id = ?1 ORDER BY id DESC",
772 )?;
773
774 let entries: Vec<(i64, Option<i64>, String, String)> = stmt
775 .query_map(params![batch_id], |row| {
776 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
777 })?
778 .collect::<Result<Vec<_>, _>>()?;
779
780 let mut count = 0;
781
782 for (log_id, track_id, from_path, to_path) in &entries {
783 let to = Path::new(to_path);
784 let from = Path::new(from_path);
785
786 if !to.exists() {
787 db.conn
789 .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
790 continue;
791 }
792
793 if let Some(parent) = from.parent() {
795 std::fs::create_dir_all(parent)?;
796 }
797
798 move_file(to, from)?;
800
801 if let Some(tid) = track_id {
803 db.conn.execute(
804 "UPDATE tracks SET path = ?1 WHERE id = ?2",
805 params![from_path, tid],
806 )?;
807 db.conn.execute(
808 "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
809 params![from_path, to_path],
810 )?;
811 }
812
813 if let Some(parent) = to.parent() {
815 remove_empty_dirs(parent);
816 }
817
818 db.conn
819 .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
820
821 count += 1;
822 }
823
824 Ok(count)
825}
826
827fn remove_empty_dirs(dir: &Path) {
829 let mut current = dir.to_path_buf();
830 loop {
831 if std::fs::read_dir(¤t)
832 .map(|mut d| d.next().is_none())
833 .unwrap_or(false)
834 {
835 if std::fs::remove_dir(¤t).is_err() {
836 break;
837 }
838 match current.parent() {
839 Some(p) => current = p.to_path_buf(),
840 None => break,
841 }
842 } else {
843 break;
844 }
845 }
846}
847
848fn move_file(from: &Path, to: &Path) -> Result<(), OrganizeError> {
850 match std::fs::rename(from, to) {
851 Ok(()) => Ok(()),
852 Err(e) if e.raw_os_error() == Some(18) => {
853 std::fs::copy(from, to)?;
855 std::fs::remove_file(from)?;
856 Ok(())
857 }
858 Err(e) => Err(e.into()),
859 }
860}
861
862fn paths_equal(a: &Path, b: &Path) -> bool {
864 if a == b {
865 return true;
866 }
867 if let (Ok(ca), Ok(cb)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
869 return ca == cb;
870 }
871 false
872}
873
874fn resolve_base_dir(base_dir: Option<&Path>) -> Result<PathBuf, OrganizeError> {
875 if let Some(dir) = base_dir {
876 return Ok(dir.to_path_buf());
877 }
878
879 let config = crate::config::Config::load()
881 .map_err(|e| OrganizeError::Io(std::io::Error::other(e.to_string())))?;
882
883 config.library.folders.into_iter().next().ok_or_else(|| {
884 OrganizeError::Io(std::io::Error::other(
885 "no library folders configured; use --base-dir",
886 ))
887 })
888}
889
890fn chrono_batch_id() -> String {
891 let now = std::time::SystemTime::now()
892 .duration_since(std::time::UNIX_EPOCH)
893 .unwrap_or_default();
894 format!("batch-{}", now.as_nanos())
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900 use crate::db::connection::Database;
901 use crate::db::queries::TrackMeta;
902 use crate::db::schema;
903
904 fn test_db() -> Database {
905 let conn = rusqlite::Connection::open_in_memory().unwrap();
906 conn.pragma_update(None, "foreign_keys", "on").unwrap();
907 schema::create_tables(&conn).unwrap();
908 Database { conn }
909 }
910
911 fn sample_meta(title: &str, artist: &str, album: &str) -> TrackMeta {
912 TrackMeta {
913 title: title.into(),
914 artist: artist.into(),
915 album_artist: Some(artist.into()),
916 album: album.into(),
917 date: Some("1997-06-16".into()),
918 disc: Some(1),
919 track_number: Some(1),
920 genre: Some("Rock".into()),
921 label: None,
922 duration_ms: Some(240_000),
923 codec: Some("FLAC".into()),
924 sample_rate: Some(44100),
925 bit_depth: Some(16),
926 channels: Some(2),
927 bitrate: Some(1000),
928 size_bytes: Some(30_000_000),
929 mtime: Some(1700000000),
930 path: None,
931 source: "local".into(),
932 remote_id: None,
933 remote_url: None,
934 }
935 }
936
937 #[test]
938 fn track_metadata_provider_fields() {
939 let track = TrackRow {
940 id: 1,
941 album_id: Some(1),
942 artist_id: Some(1),
943 artist_name: "Radiohead".into(),
944 album_artist_name: "Radiohead".into(),
945 album_title: "OK Computer".into(),
946 disc: Some(1),
947 track_number: Some(3),
948 title: "Subterranean Homesick Alien".into(),
949 duration_ms: Some(240_000),
950 path: Some("/music/ok_computer/03.flac".into()),
951 codec: Some("FLAC".into()),
952 sample_rate: Some(44100),
953 bit_depth: Some(16),
954 channels: Some(2),
955 bitrate: Some(1000),
956 genre: Some("Alternative".into()),
957 source: "local".into(),
958 remote_id: None,
959 cached_path: None,
960 };
961
962 let meta = TrackMetadata::from_track_row(&track, Some("1997-06-16"));
963 assert_eq!(
964 meta.get_field("title").as_deref(),
965 Some("Subterranean Homesick Alien")
966 );
967 assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
968 assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
969 assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
970 assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
971 assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
972 assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
973 assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
974 assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
975 assert_eq!(meta.get_field("nonexistent"), None);
976 }
977
978 #[test]
979 fn sanitize_replaces_illegal_chars() {
980 assert_eq!(sanitize_path_component("AC/DC"), "AC_DC");
981 assert_eq!(sanitize_path_component("What?"), "What_");
982 assert_eq!(sanitize_path_component("a:b*c"), "a_b_c");
983 assert_eq!(sanitize_path_component("normal"), "normal");
984 }
985
986 #[test]
987 fn sanitize_relative_path_splits() {
988 let result = sanitize_relative_path("Artist/Album/Track");
989 assert_eq!(result, PathBuf::from("Artist/Album/Track"));
990
991 let result = sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag");
992 assert_eq!(
993 result,
994 PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
995 );
996 }
997
998 #[test]
999 fn sanitize_relative_path_rejects_traversal() {
1000 let result = sanitize_relative_path("../../../../etc/passwd");
1002 assert_eq!(result, PathBuf::from("etc/passwd"));
1003
1004 let result = sanitize_relative_path("Artist/../../../outside");
1005 assert_eq!(result, PathBuf::from("Artist/outside"));
1006
1007 let result = sanitize_relative_path("./Artist/./Album");
1009 assert_eq!(result, PathBuf::from("Artist/Album"));
1010
1011 let result = sanitize_relative_path("Artist/Album/Track");
1013 assert_eq!(result, PathBuf::from("Artist/Album/Track"));
1014 }
1015
1016 #[test]
1017 fn acdc_artist_name_sanitized() {
1018 let track = TrackRow {
1020 id: 1,
1021 album_id: Some(1),
1022 artist_id: Some(1),
1023 artist_name: "AC/DC".into(),
1024 album_artist_name: "AC/DC".into(),
1025 album_title: "Highway to Hell".into(),
1026 disc: Some(1),
1027 track_number: Some(1),
1028 title: "Highway to Hell".into(),
1029 duration_ms: None,
1030 path: Some("/music/test.flac".into()),
1031 codec: None,
1032 sample_rate: None,
1033 bit_depth: None,
1034 channels: None,
1035 bitrate: None,
1036 genre: None,
1037 source: "local".into(),
1038 remote_id: None,
1039 cached_path: None,
1040 };
1041 let meta = TrackMetadata::from_track_row(&track, Some("1979"));
1042 assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
1043 let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
1044 assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
1045 }
1046
1047 #[test]
1048 fn format_string_evaluation() {
1049 let track = TrackRow {
1050 id: 1,
1051 album_id: Some(1),
1052 artist_id: Some(1),
1053 artist_name: "Radiohead".into(),
1054 album_artist_name: "Radiohead".into(),
1055 album_title: "OK Computer".into(),
1056 disc: Some(1),
1057 track_number: Some(1),
1058 title: "Airbag".into(),
1059 duration_ms: Some(240_000),
1060 path: Some("/music/test.flac".into()),
1061 codec: Some("FLAC".into()),
1062 sample_rate: Some(44100),
1063 bit_depth: Some(16),
1064 channels: Some(2),
1065 bitrate: Some(1000),
1066 genre: None,
1067 source: "local".into(),
1068 remote_id: None,
1069 cached_path: None,
1070 };
1071
1072 let meta = TrackMetadata::from_track_row(&track, Some("1997-06-16"));
1073 let pattern =
1074 "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
1075 let result = format::format(pattern, &meta).unwrap();
1076 assert_eq!(result, "Radiohead/(1997) OK Computer/01. Airbag");
1077 }
1078
1079 #[test]
1080 fn preview_does_not_move_files() {
1081 let db = test_db();
1082 let tmp = std::env::temp_dir().join(format!("koan-organize-test-{}", std::process::id()));
1083 std::fs::create_dir_all(&tmp).unwrap();
1084
1085 let test_file = tmp.join("test.flac");
1087 std::fs::write(&test_file, b"fake audio data").unwrap();
1088
1089 let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1090 meta.path = Some(test_file.to_string_lossy().into());
1091 queries::upsert_track(&db.conn, &meta).unwrap();
1092
1093 let result = preview(&db, "%album artist%/%album%/%title%", Some(&tmp)).unwrap();
1094 assert!(test_file.exists());
1096 assert!(!result.moves.is_empty());
1097
1098 std::fs::remove_dir_all(&tmp).ok();
1100 }
1101
1102 #[test]
1103 fn execute_moves_files_and_undo_reverts() {
1104 let db = test_db();
1105 let tmp = std::env::temp_dir().join(format!("koan-organize-exec-{}", std::process::id()));
1106 let src_dir = tmp.join("src");
1107 std::fs::create_dir_all(&src_dir).unwrap();
1108
1109 let test_file = src_dir.join("test.flac");
1111 std::fs::write(&test_file, b"fake audio data").unwrap();
1112
1113 let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1114 meta.path = Some(test_file.to_string_lossy().into());
1115 queries::upsert_track(&db.conn, &meta).unwrap();
1116
1117 let result = execute(&db, "%album artist%/%album%/%title%", Some(&tmp)).unwrap();
1119 assert_eq!(result.moves.len(), 1);
1120 assert!(!test_file.exists()); let dest = &result.moves[0].to;
1122 assert!(dest.exists()); let undone = undo(&db).unwrap();
1126 assert_eq!(undone, 1);
1127 assert!(test_file.exists()); assert!(!dest.exists()); std::fs::remove_dir_all(&tmp).ok();
1132 }
1133
1134 #[test]
1135 fn ancillary_file_detection() {
1136 let tmp = std::env::temp_dir().join(format!("koan-organize-anc-{}", std::process::id()));
1137 std::fs::create_dir_all(&tmp).unwrap();
1138
1139 std::fs::write(tmp.join("cover.jpg"), b"img").unwrap();
1140 std::fs::write(tmp.join("cover.png"), b"img").unwrap();
1141 std::fs::write(tmp.join("album.cue"), b"cue").unwrap();
1142 std::fs::write(tmp.join("rip.log"), b"log").unwrap();
1143 std::fs::write(tmp.join("track.flac"), b"audio").unwrap(); let found = find_ancillary_files(&tmp);
1146 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1147 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1148 assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1149 assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1150 assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1151
1152 std::fs::remove_dir_all(&tmp).ok();
1153 }
1154
1155 #[test]
1156 fn extension_not_clobbered_by_dots_in_title() {
1157 let db = test_db();
1160 let tmp = std::env::temp_dir().join(format!("koan-organize-dots-{}", std::process::id()));
1161 let src_dir = tmp.join("src");
1162 std::fs::create_dir_all(&src_dir).unwrap();
1163
1164 let test_file = src_dir.join("CHROMA 011 A.L.O.E II.flac");
1166 std::fs::write(&test_file, b"fake").unwrap();
1167
1168 let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
1169 meta.track_number = Some(10);
1170 meta.disc = Some(1);
1171 meta.date = Some("2025-11-21".into());
1172 meta.codec = Some("FLAC".into());
1173 meta.album_artist = Some("Bicep".into());
1174 meta.path = Some(test_file.to_string_lossy().into());
1175 queries::upsert_track(&db.conn, &meta).unwrap();
1176
1177 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
1178 let result = preview(&db, pattern, Some(&tmp)).unwrap();
1179 assert_eq!(result.moves.len(), 1);
1180
1181 let dest_name = result.moves[0]
1182 .to
1183 .file_name()
1184 .unwrap()
1185 .to_string_lossy()
1186 .to_string();
1187 assert_eq!(dest_name, "0110. Bicep - CHROMA 011 A.L.O.E II.flac");
1189
1190 std::fs::remove_dir_all(&tmp).ok();
1191 }
1192
1193 #[test]
1194 fn extension_preserved_for_tracknumber_dot() {
1195 let db = test_db();
1197 let tmp = std::env::temp_dir().join(format!("koan-organize-trkdot-{}", std::process::id()));
1198 let src_dir = tmp.join("src");
1199 std::fs::create_dir_all(&src_dir).unwrap();
1200
1201 let test_file = src_dir.join("CHROMA 012 TANGZ II.flac");
1202 std::fs::write(&test_file, b"fake").unwrap();
1203
1204 let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
1205 meta.track_number = Some(11);
1206 meta.disc = Some(1);
1207 meta.date = Some("2025-11-21".into());
1208 meta.codec = Some("FLAC".into());
1209 meta.album_artist = Some("Bicep".into());
1210 meta.path = Some(test_file.to_string_lossy().into());
1211 queries::upsert_track(&db.conn, &meta).unwrap();
1212
1213 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
1214 let result = preview(&db, pattern, Some(&tmp)).unwrap();
1215 assert_eq!(result.moves.len(), 1);
1216
1217 let dest_name = result.moves[0]
1218 .to
1219 .file_name()
1220 .unwrap()
1221 .to_string_lossy()
1222 .to_string();
1223 assert_eq!(dest_name, "0111. Bicep - CHROMA 012 TANGZ II.flac");
1224
1225 std::fs::remove_dir_all(&tmp).ok();
1226 }
1227}