Skip to main content

koan_core/index/
scanner.rs

1use std::collections::HashSet;
2use std::panic::{AssertUnwindSafe, catch_unwind};
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, AtomicU64};
6use std::time::UNIX_EPOCH;
7
8use rayon::prelude::*;
9
10use crate::db::connection::Database;
11use crate::db::queries::{self, TrackMeta};
12
13use super::metadata::{self, is_audio_file};
14use super::playlist_files::is_playlist_file;
15
16/// Result of a folder scan.
17#[derive(Debug, Default)]
18pub struct ScanResult {
19    pub added: usize,
20    pub updated: usize,
21    pub removed: usize,
22    /// Files found at a new path that kept the track they had at the old one,
23    /// and so are counted neither as added nor as removed.
24    pub moved: usize,
25    pub skipped: usize,
26    /// Directory entries walkdir could not read — unreadable subtrees, symlink
27    /// loops. Their contents are absent from the scan entirely.
28    pub unreadable: usize,
29    /// Playlist files read into new or changed playlists.
30    pub playlists: usize,
31    /// Paths of the tracks deleted or demoted to remote-only, so a caller can
32    /// show what a removal actually took.
33    pub removed_paths: Vec<String>,
34    pub errors: Vec<(PathBuf, String)>,
35    /// Stopped early because someone asked. What it had done is still done.
36    pub cancelled: bool,
37    /// The scan stopped short of writing what it found: the reader could not
38    /// start or panicked, or a transaction failed to begin or commit. A file
39    /// that alone could not be read or stored is an error against that file
40    /// instead. Nothing is removed against an incomplete scan, in any of its
41    /// folders, since a file it lost may be one moved from another.
42    pub incomplete: bool,
43    /// Tracks this scan made, which a file gone from elsewhere may turn out to
44    /// be (see `queries::adopt_moved_files`).
45    arrived: Vec<i64>,
46}
47
48/// How a scan should behave.
49#[derive(Debug, Clone, Default)]
50pub struct ScanOptions {
51    /// Re-read tags for every file, ignoring `scan_cache`.
52    pub force: bool,
53    /// Set from another thread to stop early.
54    ///
55    /// Checked between transactions, so a cancelled scan keeps everything it
56    /// had already committed rather than throwing the work away — stopping is
57    /// "stop here", not "undo".
58    pub cancel: Option<Arc<AtomicBool>>,
59    /// Delete stale tracks even when the proportion missing looks like a mount
60    /// failure. Lifts the removal-fraction brake only — a folder that yields no
61    /// audio files is still left alone, and an IO error still never counts as
62    /// "file gone".
63    pub force_remove: bool,
64    /// Counts the files whose tags the scan will read, raised as each
65    /// directory's are known: with the scan cache, only the changed files,
66    /// which is what a progress bar over `on_track` counts against.
67    pub to_read: Option<Arc<AtomicU64>>,
68}
69
70/// Files per transaction. Bounds peak memory (only one chunk's metadata is
71/// resident) and caps what an interrupted scan loses; committed chunks land in
72/// `scan_cache`, so the next run resumes rather than restarting.
73#[cfg(not(test))]
74const CHUNK_SIZE: usize = 1000;
75#[cfg(test)]
76const CHUNK_SIZE: usize = 4;
77
78/// Info about a scanned track, passed to the progress callback.
79pub struct ScanEvent<'a> {
80    pub artist: &'a str,
81    pub album: &'a str,
82    pub title: &'a str,
83    pub path: &'a Path,
84    pub is_new: bool,
85}
86
87/// Scan a folder recursively for audio files and index them into the database.
88/// The optional `on_track` callback is invoked for each successfully indexed track.
89pub fn scan_folder(
90    db: &Database,
91    path: &Path,
92    opts: ScanOptions,
93    on_track: Option<&dyn Fn(ScanEvent)>,
94) -> ScanResult {
95    scan_folders(
96        db,
97        std::slice::from_ref(&path.to_path_buf()),
98        opts,
99        None,
100        on_track,
101    )
102}
103
104/// [`scan_folder`] over several folders, telling `on_started` how many audio
105/// files there are once every folder has been walked. The count comes from the
106/// same walk the scan uses: a separate counting walk would double the directory
107/// traversal, which on a network mount is most of the cost of a rescan.
108pub fn scan_folders(
109    db: &Database,
110    folders: &[PathBuf],
111    mut opts: ScanOptions,
112    on_started: Option<&dyn Fn(u64)>,
113    on_track: Option<&dyn Fn(ScanEvent)>,
114) -> ScanResult {
115    let _turn = super::lane::enter(folders, &mut opts);
116    scan_folders_in_lane(db, folders, &opts, on_started, on_track)
117}
118
119fn scan_folders_in_lane(
120    db: &Database,
121    folders: &[PathBuf],
122    opts: &ScanOptions,
123    on_started: Option<&dyn Fn(u64)>,
124    on_track: Option<&dyn Fn(ScanEvent)>,
125) -> ScanResult {
126    let walked: Vec<(PathBuf, Walked, ScanResult)> = folders
127        .iter()
128        .map(|folder| {
129            // The files under it are stored as the directory spells them; the
130            // root has to agree, or nothing under it matches a path an earlier
131            // scan stored.
132            let path = library_folder(db, folder);
133            let mut result = ScanResult::default();
134            let found = walk(&path, &mut result);
135            (path, found, result)
136        })
137        .collect();
138    if let Some(started) = on_started {
139        started(
140            walked
141                .iter()
142                .map(|(_, found, _)| found.audio.len() as u64)
143                .sum(),
144        );
145    }
146
147    // Every folder is indexed before any is pruned, so a file moved from one
148    // library folder to another is found at its new path before it is missed
149    // at its old one, and keeps its track.
150    let mut indexed = Vec::new();
151    let (mut cancelled, mut stopped) = (false, false);
152    for (path, found, mut result) in walked {
153        let seen = stored_paths(&found.audio);
154        if cancelled || stopped {
155            // Not indexed, but its rows may have moved into a folder that was:
156            // they are adopted with the rest.
157            result.cancelled = cancelled;
158            result.incomplete = stopped;
159            let prune = !found.audio.is_empty();
160            // Its playlists wait for a scan that reads the tracks they name.
161            indexed.push((path, Vec::new(), Vec::new(), prune, seen, result));
162            continue;
163        }
164        // A folder with no audio is taken for one not mounted, as stale
165        // removal takes it: its playlists are read but none forgotten.
166        let settled = if found.audio.is_empty() || result.unreadable > 0 {
167            Vec::new()
168        } else {
169            vec![path.clone()]
170        };
171        let prune = index_folder(db, &path, found.audio, opts, on_track, &mut result);
172        cancelled |= result.cancelled;
173        // A database that would not take one folder's writes is not asked
174        // for the next one's.
175        stopped |= result.incomplete;
176        indexed.push((path, found.playlists, settled, prune, seen, result));
177    }
178
179    let arrived: Vec<i64> = indexed
180        .iter()
181        .flat_map(|(.., result)| result.arrived.iter().copied())
182        .collect();
183    let incomplete = indexed.iter().any(|(.., result)| result.incomplete);
184    let mut total = ScanResult::default();
185    for (path, playlists, settled, prune, seen, mut result) in indexed {
186        // Stale removal decides what is missing by what the scan did *not*
187        // see. After a cancellation that is most of the library, so it would
188        // delete a library rather than tidy one; after lost writes, a file
189        // lost in one folder may be one moved from another. Moves are adopted
190        // all the same: see `remove_stale`.
191        if prune {
192            remove_stale(
193                db,
194                &path,
195                opts.force_remove,
196                !cancelled && !incomplete,
197                &arrived,
198                &seen,
199                &mut result,
200            );
201        }
202        if !result.cancelled {
203            result.playlists += super::playlist_files::import(db, &playlists, &settled);
204        }
205        merge(&mut total, result);
206    }
207    // A moved file was counted as added when its new path was indexed.
208    total.added = total.added.saturating_sub(total.moved);
209    if total.added > 0 {
210        super::playlist_files::refresh_m3u(db);
211    }
212    total
213}
214
215/// Index a library folder's files. Whether its stale rows may then be
216/// removed.
217fn index_folder(
218    db: &Database,
219    path: &Path,
220    audio_files: Vec<PathBuf>,
221    opts: &ScanOptions,
222    on_track: Option<&dyn Fn(ScanEvent)>,
223    result: &mut ScanResult,
224) -> bool {
225    let total_files = audio_files.len();
226    log::info!("found {} audio files in {}", total_files, path.display());
227
228    let scope = [path.to_path_buf()];
229    index_files(db, audio_files, &scope, opts, on_track, result, path);
230
231    // Remove tracks for files that no longer exist. A folder that yielded nothing
232    // is far more likely to be an unmounted volume than a library someone emptied,
233    // and stale rows are recoverable where deleted play history is not.
234    if total_files == 0 {
235        log::error!(
236            "{} contains no audio files — skipping stale-track removal. \
237             If this folder should have music in it, it is probably not mounted or not readable.",
238            path.display()
239        );
240        return false;
241    }
242    true
243}
244
245/// Rescan directories inside the library folders, and nothing else.
246///
247/// What the folder watcher runs: a change touches a handful of directories,
248/// and walking the whole library to find them costs a spinning disk minutes.
249/// Each directory is authoritative for what lies under it — files found are
250/// indexed, rows for files no longer there are removed — which is what
251/// separates this from `import_paths`. A directory that no longer exists
252/// removes everything that was under it.
253///
254/// Removal is only trusted while the library folder holding the directory is
255/// itself readable and non-empty: an unmounted volume leaves an empty mount
256/// point, and every directory under it would otherwise read as deleted. A
257/// directory that could not be read in full, or one reached through a symlink
258/// whose target is gone, removes nothing. Directories
259/// outside every library folder are ignored; one that is a library folder
260/// gets the same treatment as `scan_folder`.
261pub fn scan_dirs(
262    db: &Database,
263    library: &[PathBuf],
264    dirs: &[PathBuf],
265    mut opts: ScanOptions,
266    on_track: Option<&dyn Fn(ScanEvent)>,
267) -> ScanResult {
268    let _turn = super::lane::enter(dirs, &mut opts);
269    let mut result = ScanResult::default();
270    let mut spelling = super::spelling::Spelling::default();
271    let library: Vec<PathBuf> = library.iter().map(|f| library_folder(db, f)).collect();
272    let dirs: Vec<PathBuf> = dirs.iter().map(|d| spelling.on_disk(d)).collect();
273
274    let mut files = Vec::new();
275    let mut playlists = Vec::new();
276    let mut walked = Vec::new();
277    let mut settled = Vec::new();
278    for dir in minimal_dirs(dirs) {
279        let Some(root) = library.iter().find(|root| dir.starts_with(root)) else {
280            log::warn!("not in a library folder, not scanning: {}", dir.display());
281            continue;
282        };
283        if dir == *root {
284            merge(
285                &mut result,
286                scan_folders_in_lane(db, std::slice::from_ref(root), &opts, None, on_track),
287            );
288            continue;
289        }
290        if !is_populated(root) {
291            log::warn!(
292                "{} is empty or unreadable — not scanning {}",
293                root.display(),
294                dir.display()
295            );
296            continue;
297        }
298        match dir.try_exists() {
299            Ok(true) => {
300                let before = result.unreadable;
301                let found = walk(&dir, &mut result);
302                files.extend(found.audio);
303                playlists.extend(found.playlists);
304                walked.push(dir.clone());
305                if result.unreadable == before {
306                    settled.push(dir);
307                }
308            }
309            Ok(false) if super::known_missing(&dir) => settled.push(dir),
310            Ok(false) => log::warn!(
311                "{} is behind a link that leads nowhere — not scanning it",
312                dir.display()
313            ),
314            Err(e) => log::warn!("cannot tell whether {} exists: {e}", dir.display()),
315        }
316    }
317
318    let seen = stored_paths(&files);
319    index_files(
320        db,
321        files,
322        &walked,
323        &opts,
324        on_track,
325        &mut result,
326        Path::new(""),
327    );
328    let arrived = std::mem::take(&mut result.arrived);
329    let remove = !result.incomplete && !result.cancelled;
330    // A library root scanned above has already taken its moves off `added`.
331    let counted = result.moved;
332    for dir in &settled {
333        remove_stale(db, dir, true, remove, &arrived, &seen, &mut result);
334    }
335    result.added = result.added.saturating_sub(result.moved - counted);
336    if result.cancelled {
337        return result;
338    }
339    for dir in &settled {
340        // A cover image changing is a reason to be here that no row records.
341        if let Err(e) = queries::evict_art_under(&db.conn, dir) {
342            log::warn!("cover art under {} not refreshed: {e}", dir.display());
343        }
344    }
345    result.playlists += super::playlist_files::import(db, &playlists, &settled);
346    if result.added > 0 {
347        super::playlist_files::refresh_m3u(db);
348    }
349    result
350}
351
352/// Walked files as their rows store them.
353fn stored_paths(files: &[PathBuf]) -> HashSet<String> {
354    files
355        .iter()
356        .map(|f| f.to_string_lossy().into_owned())
357        .collect()
358}
359
360/// The fewest directories that cover every one given: duplicates dropped, and
361/// any directory inside another dropped, since a scan of the outer one walks it.
362pub fn minimal_dirs(mut dirs: Vec<PathBuf>) -> Vec<PathBuf> {
363    // Sorted by component, everything inside a directory comes straight after it.
364    dirs.sort();
365    dirs.dedup();
366    let mut kept: Vec<PathBuf> = Vec::with_capacity(dirs.len());
367    for dir in dirs {
368        if !kept.last().is_some_and(|outer| dir.starts_with(outer)) {
369            kept.push(dir);
370        }
371    }
372    kept
373}
374
375/// A library folder as the disk spells it, with the rows stored under the
376/// spelling it was configured with moved there first
377/// (`sources::respell_folder`).
378fn library_folder(db: &Database, folder: &Path) -> PathBuf {
379    let path = super::spelling::on_disk(folder);
380    if path != folder {
381        let respelled = queries::write_transaction(&db.conn)
382            .map_err(crate::db::connection::DbError::from)
383            .and_then(|tx| {
384                queries::sources::respell_folder(&tx, folder, &path)?;
385                Ok(tx.commit()?)
386            });
387        if let Err(e) = respelled {
388            log::warn!(
389                "could not move {} to {}: {e}",
390                folder.display(),
391                path.display()
392            );
393        }
394    }
395    path
396}
397
398/// Whether a library folder is there to scan: readable, with something in it.
399fn is_populated(root: &Path) -> bool {
400    std::fs::read_dir(root).is_ok_and(|mut entries| entries.next().is_some())
401}
402
403fn merge(total: &mut ScanResult, r: ScanResult) {
404    total.cancelled |= r.cancelled;
405    total.incomplete |= r.incomplete;
406    total.added += r.added;
407    total.updated += r.updated;
408    total.removed += r.removed;
409    total.moved += r.moved;
410    total.skipped += r.skipped;
411    total.unreadable += r.unreadable;
412    total.playlists += r.playlists;
413    total.removed_paths.extend(r.removed_paths);
414    total.errors.extend(r.errors);
415}
416
417/// What a walk of a directory found.
418#[derive(Default)]
419struct Walked {
420    audio: Vec<PathBuf>,
421    /// Playlist files: see `playlist_files`.
422    playlists: Vec<PathBuf>,
423}
424
425/// Every audio and playlist file under `path`. `follow_links` means a symlink
426/// pointing at a sibling directory inside the library indexes its files under
427/// both paths.
428///
429/// A path that is not UTF-8 is left out: the database stores paths as text, so
430/// it would be stored under a name no stat finds, and the same scan would
431/// remove it again along with its play history. So is anything under a name
432/// the watcher ignores (see [`super::watch::is_ignored`]), below `path` itself.
433fn walk(path: &Path, result: &mut ScanResult) -> Walked {
434    let mut found = Walked::default();
435    let entries = walkdir::WalkDir::new(path)
436        .follow_links(true)
437        .into_iter()
438        .filter_entry(|e| e.depth() == 0 || !super::watch::is_ignored(e.file_name()));
439    for entry in entries {
440        match entry {
441            Ok(e) if e.path().to_str().is_none() => {
442                if e.file_type().is_file() {
443                    log::warn!("skipping a path that is not UTF-8: {}", e.path().display());
444                }
445            }
446            Ok(e) if e.file_type().is_file() && is_audio_file(e.path()) => {
447                found.audio.push(e.path().to_path_buf())
448            }
449            Ok(e) if e.file_type().is_file() && is_playlist_file(e.path()) => {
450                found.playlists.push(e.path().to_path_buf())
451            }
452            Ok(_) => {}
453            Err(e) => {
454                result.unreadable += 1;
455                log::warn!("skipping unreadable entry under {}: {}", path.display(), e);
456            }
457        }
458    }
459    found
460}
461
462/// Read and store the files that changed since they were last indexed.
463/// `scope` holds the directories and files they were walked from: the scan
464/// cache is read for those alone.
465///
466/// A run it could not finish marks the result `incomplete`, with the reason
467/// in `result.errors` against `context`. A transaction that will not begin or
468/// commit ends the run; batches committed before it stay committed.
469fn index_files(
470    db: &Database,
471    mut audio_files: Vec<PathBuf>,
472    scope: &[PathBuf],
473    opts: &ScanOptions,
474    on_track: Option<&dyn Fn(ScanEvent)>,
475    result: &mut ScanResult,
476    context: &Path,
477) {
478    let total_files = audio_files.len();
479    if total_files == 0 {
480        return;
481    }
482
483    // The scope's cache entries in one read, rather than a lookup per file.
484    let files_to_scan: Vec<PathBuf> = if opts.force {
485        std::mem::take(&mut audio_files)
486    } else {
487        let scan_cache = queries::load_scan_cache(&db.conn, scope).unwrap_or_default();
488        // One stat per file; in parallel, since on a network mount each is a round trip.
489        audio_files
490            .par_iter()
491            .filter(|file_path| {
492                let Ok(file_meta) = std::fs::metadata(file_path) else {
493                    return true;
494                };
495                let mtime = file_meta
496                    .modified()
497                    .ok()
498                    .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
499                    .map(|d| d.as_secs() as i64)
500                    .unwrap_or(0);
501                let size = file_meta.len() as i64;
502                let path_str = file_path.to_string_lossy();
503                match scan_cache.get(path_str.as_ref()) {
504                    Some(&(cached_mtime, cached_size)) => {
505                        mtime != cached_mtime || size != cached_size
506                    }
507                    None => true,
508                }
509            })
510            .cloned()
511            .collect()
512    };
513
514    result.skipped += total_files - files_to_scan.len();
515    if let Some(to_read) = &opts.to_read {
516        to_read.fetch_add(
517            files_to_scan.len() as u64,
518            std::sync::atomic::Ordering::Relaxed,
519        );
520    }
521
522    // Tag reads and database writes run at the same time.
523    //
524    // Reading the whole library up front and writing it in one transaction
525    // blocks every other writer for the length of the scan and loses all of it
526    // on interrupt, so writes stay chunked. But doing that as read-chunk,
527    // write-chunk, read-chunk leaves the disk idle for every write and the CPU
528    // idle for every read — on a library of any size that is most of the run.
529    //
530    // Instead the reads stream: a worker pool walks every file and pushes
531    // results down a bounded channel while this thread batches them into
532    // transactions. The bound is what caps memory, in place of the chunking.
533    let (send, recv) = crossbeam_channel::bounded::<(PathBuf, Result<TrackMeta, String>)>(
534        CHUNK_SIZE.saturating_mul(2),
535    );
536    let cancel = opts.cancel.clone();
537    let reader = std::thread::Builder::new()
538        .name("koan-scan-read".into())
539        .spawn(move || {
540            // Stops at the first failed send (the consumer is gone) or cancel,
541            // and says whether it stopped short.
542            files_to_scan
543                .par_iter()
544                .try_for_each(|file_path| {
545                    if cancel
546                        .as_ref()
547                        .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
548                    {
549                        return Err(());
550                    }
551                    send.send((
552                        file_path.clone(),
553                        isolate_read(file_path, metadata::read_metadata),
554                    ))
555                    .map_err(|_| ())
556                })
557                .is_err()
558        });
559    if let Err(e) = &reader {
560        log::error!("failed to spawn scan reader: {}", e);
561        result
562            .errors
563            .push((context.to_path_buf(), format!("scan error: {}", e)));
564        result.incomplete = true;
565        return;
566    }
567
568    // Set when a transaction fails and the loop leaves with files unread.
569    let mut stopped = false;
570    loop {
571        // Blocks until a full batch is ready or the readers have finished.
572        let batch: Vec<(PathBuf, Result<TrackMeta, String>)> =
573            recv.iter().take(CHUNK_SIZE).collect();
574        if batch.is_empty() {
575            break;
576        }
577        if opts
578            .cancel
579            .as_ref()
580            .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
581        {
582            log::info!("scan cancelled — keeping what was already committed");
583            result.cancelled = true;
584            break;
585        }
586
587        let tx = match crate::db::queries::write_transaction(&db.conn) {
588            Ok(tx) => tx,
589            Err(e) => {
590                log::error!("failed to begin scan transaction: {}", e);
591                result
592                    .errors
593                    .push((context.to_path_buf(), format!("db error: {}", e)));
594                result.incomplete = true;
595                stopped = true;
596                break;
597            }
598        };
599
600        let (mut added, mut updated) = (0usize, 0usize);
601        let mut arrived = Vec::new();
602        // Told once the batch is committed: a rolled-back track was never added.
603        let mut stored = Vec::new();
604        for (file_path, meta_result) in batch {
605            match meta_result {
606                Ok(meta) => match queries::upsert_track_status(&tx, &meta) {
607                    Ok((track_id, is_new)) => {
608                        if is_new {
609                            added += 1;
610                            arrived.push(track_id);
611                        } else {
612                            updated += 1;
613                        }
614                        if let Err(e) = queries::update_scan_cache(
615                            &tx,
616                            meta.path.as_deref().unwrap_or(""),
617                            meta.mtime.unwrap_or(0),
618                            meta.size_bytes.unwrap_or(0),
619                            track_id,
620                        ) {
621                            // Not fatal, but every future scan re-reads this file's tags.
622                            log::warn!("failed to cache {}: {}", file_path.display(), e);
623                        }
624                        if on_track.is_some() {
625                            stored.push((file_path, meta, is_new));
626                        }
627                    }
628                    Err(e) => {
629                        result.errors.push((file_path, format!("db error: {}", e)));
630                    }
631                },
632                Err(e) => {
633                    result.errors.push((file_path, e));
634                }
635            }
636        }
637
638        match tx.commit() {
639            Ok(()) => {
640                result.added += added;
641                result.updated += updated;
642                result.arrived.extend(arrived);
643                if let Some(cb) = &on_track {
644                    for (file_path, meta, is_new) in &stored {
645                        cb(ScanEvent {
646                            artist: &meta.artist,
647                            album: &meta.album,
648                            title: &meta.title,
649                            path: file_path,
650                            is_new: *is_new,
651                        });
652                    }
653                }
654            }
655            Err(e) => {
656                log::error!("failed to commit scan transaction: {}", e);
657                result
658                    .errors
659                    .push((context.to_path_buf(), format!("db error: {}", e)));
660                result.incomplete = true;
661                stopped = true;
662                break;
663            }
664        }
665    }
666
667    // Dropped before the join: readers parked on a full channel only wake
668    // when their send fails.
669    drop(recv);
670    if let Ok(handle) = reader {
671        match handle.join() {
672            // A cancel that lands while the channel is empty stops the
673            // readers before they send again, and the loop above ends on an
674            // empty batch as though every file had been read. A send fails
675            // only once the loop has left, on a cancel, a failed transaction
676            // or with the readers done, so stopping short of a scan that was
677            // not stopped means cancelled.
678            Ok(true) if !stopped => result.cancelled = true,
679            Ok(_) => {}
680            Err(_) => {
681                log::error!("scan reader thread panicked");
682                result.incomplete = true;
683            }
684        }
685    }
686}
687
688/// Remove the rows under `path` whose files are gone, in one transaction,
689/// after giving those that moved (to one of `arrived`) their tracks back.
690/// Without `remove`, only the moves: a scan that was cancelled or lost writes
691/// cannot say what is gone, but a track it made for a moved file must find
692/// the old row now, since the next scan finds that file cached and does not
693/// count it arrived.
694/// `walked` is every audio file the walk under `path` found, as stored: those
695/// are present without asking the filesystem again.
696fn remove_stale(
697    db: &Database,
698    path: &Path,
699    force_remove: bool,
700    remove: bool,
701    arrived: &[i64],
702    walked: &HashSet<String>,
703    result: &mut ScanResult,
704) {
705    if !remove && arrived.is_empty() {
706        return;
707    }
708    let tx = match crate::db::queries::write_transaction(&db.conn) {
709        Ok(tx) => tx,
710        Err(e) => {
711            log::error!("failed to begin stale-removal transaction: {}", e);
712            result
713                .errors
714                .push((path.to_path_buf(), format!("db error: {}", e)));
715            return;
716        }
717    };
718    let moved = match queries::adopt_moved_files(&tx, path, arrived, Some(walked)) {
719        Ok(moved) => moved,
720        Err(e) => {
721            log::error!("failed to match moved files: {}", e);
722            result.errors.push((path.to_path_buf(), e.to_string()));
723            return;
724        }
725    };
726    result.moved += moved;
727    if !remove {
728        if let Err(e) = tx.commit() {
729            log::error!("failed to commit moved files: {}", e);
730            result
731                .errors
732                .push((path.to_path_buf(), format!("db error: {}", e)));
733            result.moved -= moved;
734        }
735        return;
736    }
737    match queries::remove_stale_tracks_walked(&tx, path, force_remove, Some(walked)) {
738        Ok(removed) => {
739            if let Err(e) = tx.commit() {
740                log::error!("failed to commit stale removals: {}", e);
741                result
742                    .errors
743                    .push((path.to_path_buf(), format!("db error: {}", e)));
744                result.moved -= moved;
745                return;
746            }
747            result.removed += removed.len();
748            result.removed_paths.extend(removed);
749        }
750        Err(e) => {
751            log::error!("failed to remove stale tracks: {}", e);
752            result.errors.push((path.to_path_buf(), e.to_string()));
753            // The brake refuses before removing anything; the moves found
754            // stand.
755            if matches!(e, crate::db::connection::DbError::UnsafeBulkDelete(_))
756                && let Err(e) = tx.commit()
757            {
758                log::error!("failed to commit moved files: {}", e);
759                result
760                    .errors
761                    .push((path.to_path_buf(), format!("db error: {}", e)));
762                result.moved -= moved;
763            }
764        }
765    }
766}
767
768/// Run a tag read, containing a panic from the parsers. Hostile input (a bogus
769/// ID3v2 frame size, a pathological MP4 atom tree) can panic inside lofty or
770/// symphonia; rayon re-raises that at `collect()`, which would otherwise abort
771/// the whole scan over one file and not even name it.
772fn isolate_read(
773    path: &Path,
774    read: impl FnOnce(&Path) -> Result<TrackMeta, metadata::MetadataError>,
775) -> Result<TrackMeta, String> {
776    match catch_unwind(AssertUnwindSafe(|| read(path))) {
777        Ok(result) => result.map_err(|e| e.to_string()),
778        Err(_) => Err(format!("panicked while reading tags: {}", path.display())),
779    }
780}
781
782/// What an import of specific files produced.
783#[derive(Debug, Default)]
784pub struct ImportResult {
785    /// Library rows for the imported files, in the order their paths were
786    /// walked. This is what a caller queues.
787    pub track_ids: Vec<i64>,
788    pub added: usize,
789    pub updated: usize,
790    pub errors: Vec<(PathBuf, String)>,
791}
792
793/// Index specific files into the library, wherever they live.
794///
795/// This is the drop-a-folder-on-the-queue path: the files named here are not
796/// under a configured library folder, and organize is what moves them there
797/// afterwards. Nothing is ever removed — the caller named these paths, so there
798/// is no directory listing to reconcile against and nothing to prune, which is
799/// what separates this from `scan_folder`.
800///
801/// Directories are walked recursively. Order is by path, so an album lands in
802/// the order its files are numbered.
803pub fn import_paths(db: &Database, paths: &[PathBuf]) -> ImportResult {
804    let mut result = ImportResult::default();
805
806    let mut files: Vec<PathBuf> = Vec::new();
807    let mut scope = Vec::new();
808    let mut seen = std::collections::HashSet::new();
809    // Dropped paths are spelled by whoever dropped them, and Foundation spells
810    // accents differently from the disk. Walked children come from the
811    // directory itself and need nothing.
812    let mut spelling = super::spelling::Spelling::default();
813    for path in paths {
814        let path = spelling.on_disk(path);
815        let mut found: Vec<PathBuf> = walkdir::WalkDir::new(&path)
816            .follow_links(true)
817            .into_iter()
818            .filter_map(Result::ok)
819            .filter(|e| e.file_type().is_file() && is_audio_file(e.path()))
820            .filter(|e| {
821                let utf8 = e.path().to_str().is_some();
822                if !utf8 {
823                    log::warn!("skipping a path that is not UTF-8: {}", e.path().display());
824                }
825                utf8
826            })
827            .map(|e| e.path().to_path_buf())
828            .collect();
829        found.sort();
830        scope.push(path);
831        // A drop can name both a folder and a file inside it.
832        files.extend(found.into_iter().filter(|f| seen.insert(f.clone())));
833    }
834
835    if files.is_empty() {
836        return result;
837    }
838
839    // Read and written as a scan reads and writes, so an unchanged file is not
840    // read again. Not in the lane: see `lane`.
841    let _importing = super::lane::importing();
842    let mut scanned = ScanResult::default();
843    index_files(
844        db,
845        files.clone(),
846        &scope,
847        &ScanOptions::default(),
848        None,
849        &mut scanned,
850        Path::new(""),
851    );
852    result.added = scanned.added;
853    result.updated = scanned.updated;
854    result.errors = scanned.errors;
855
856    // Every file's track, in the order walked: the ones just read and the
857    // ones the scan cache said were unchanged alike.
858    let mut track_of = match db
859        .conn
860        .prepare_cached("SELECT track_id FROM local_files WHERE path = ?1")
861    {
862        Ok(stmt) => stmt,
863        Err(e) => {
864            result
865                .errors
866                .push((PathBuf::new(), format!("db error: {e}")));
867            return result;
868        }
869    };
870    for file in &files {
871        if let Ok(id) = track_of.query_row([file.to_string_lossy()], |r| r.get::<_, i64>(0)) {
872            result.track_ids.push(id);
873        }
874    }
875    drop(track_of);
876
877    if result.added > 0 {
878        super::playlist_files::refresh_m3u(db);
879    }
880    result
881}
882
883/// Scan all configured library folders.
884pub fn full_scan(
885    db: &Database,
886    folders: &[PathBuf],
887    mut opts: ScanOptions,
888    on_track: Option<&dyn Fn(ScanEvent)>,
889) -> ScanResult {
890    let _turn = super::lane::enter(folders, &mut opts);
891    let existing: Vec<PathBuf> = folders
892        .iter()
893        .filter(|folder| {
894            let exists = folder.exists();
895            if !exists {
896                log::warn!("library folder does not exist: {}", folder.display());
897            }
898            exists
899        })
900        .cloned()
901        .collect();
902    let total = scan_folders_in_lane(db, &existing, &opts, None, on_track);
903    db.optimize();
904    total
905}
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910    use crate::db::connection::Database;
911    use crate::db::queries;
912    use crate::test_utils;
913
914    /// Make the commit of any batch storing a path `LIKE` `pattern` fail,
915    /// after every statement in it has succeeded: a deferred foreign key,
916    /// broken by a trigger on `scan_cache`.
917    fn fail_commits_for(db: &Database, pattern: &str) {
918        db.conn
919            .execute_batch(&format!(
920                "CREATE TABLE trap_parent (id INTEGER PRIMARY KEY);
921                 CREATE TABLE trap (parent INTEGER
922                     REFERENCES trap_parent(id) DEFERRABLE INITIALLY DEFERRED);
923                 CREATE TRIGGER spring AFTER INSERT ON scan_cache
924                     WHEN NEW.path LIKE '{pattern}'
925                     BEGIN INSERT INTO trap VALUES (1); END;"
926            ))
927            .unwrap();
928    }
929
930    fn test_db(dir: &Path) -> Database {
931        let db_path = dir.join("test.db");
932        Database::open(&db_path).unwrap()
933    }
934
935    /// `to_read` counts what the scan will read: every file the first time,
936    /// only the changed one after.
937    #[test]
938    fn to_read_counts_the_files_whose_tags_are_read() {
939        let dir = tempfile::tempdir().unwrap();
940        let music = dir.path().join("music");
941        std::fs::create_dir_all(&music).unwrap();
942        for name in ["a.wav", "b.wav", "c.wav"] {
943            test_utils::generate_wav(&music.join(name), 8000, 1, 0.1, 16);
944        }
945        let db = test_db(dir.path());
946        let counted = |db: &Database| {
947            let to_read = Arc::new(AtomicU64::new(0));
948            let opts = ScanOptions {
949                to_read: Some(to_read.clone()),
950                ..Default::default()
951            };
952            scan_folder(db, &music, opts, None);
953            to_read.load(std::sync::atomic::Ordering::Relaxed)
954        };
955        assert_eq!(counted(&db), 3);
956
957        std::fs::remove_file(music.join("b.wav")).unwrap();
958        test_utils::generate_wav(&music.join("b.wav"), 8000, 1, 0.2, 16);
959        assert_eq!(counted(&db), 1);
960    }
961
962    /// A batch lost to a failed commit may hold a file moved in from another
963    /// library folder; that folder must not remove the file's track as gone.
964    #[test]
965    fn a_failed_commit_prunes_no_folder() {
966        let dir = tempfile::tempdir().unwrap();
967        let (a, b) = (dir.path().join("a"), dir.path().join("b"));
968        std::fs::create_dir_all(&a).unwrap();
969        std::fs::create_dir_all(&b).unwrap();
970        test_utils::generate_wav(&a.join("kept.wav"), 8000, 1, 0.1, 16);
971        test_utils::generate_wav(&b.join("moved.wav"), 8000, 1, 0.1, 16);
972        // So the folder the file leaves is not taken for an unmounted one.
973        test_utils::generate_wav(&b.join("stays.wav"), 8000, 1, 0.1, 16);
974        let db = test_db(dir.path());
975        let folders = [a.clone(), b.clone()];
976        let first = scan_folders(&db, &folders, ScanOptions::default(), None, None);
977        assert_eq!(first.added, 3);
978
979        std::fs::rename(b.join("moved.wav"), a.join("moved.wav")).unwrap();
980        // The moved file's batch will not commit.
981        fail_commits_for(&db, "%moved.wav");
982
983        let events = std::sync::Mutex::new(Vec::new());
984        let on_track = |e: ScanEvent| events.lock().unwrap().push(e.path.to_path_buf());
985        let second = scan_folders(&db, &folders, ScanOptions::default(), None, Some(&on_track));
986
987        assert!(second.incomplete);
988        assert!(!second.errors.is_empty());
989        assert_eq!(second.removed, 0, "pruned against a lost batch");
990        let tracks: i64 = db
991            .conn
992            .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
993            .unwrap();
994        assert_eq!(tracks, 3);
995        assert!(
996            !events
997                .lock()
998                .unwrap()
999                .iter()
1000                .any(|p| p.ends_with("moved.wav")),
1001            "a rolled-back track was reported"
1002        );
1003    }
1004
1005    /// A scan that lost a batch elsewhere still gives a moved file its track:
1006    /// the next scan finds it cached and would no longer count it arrived.
1007    #[test]
1008    fn an_incomplete_scan_still_adopts_moves() {
1009        let dir = tempfile::tempdir().unwrap();
1010        let (one, two, three) = (
1011            dir.path().join("one"),
1012            dir.path().join("two"),
1013            dir.path().join("three"),
1014        );
1015        std::fs::create_dir_all(one.join("Album")).unwrap();
1016        std::fs::create_dir_all(&two).unwrap();
1017        std::fs::create_dir_all(&three).unwrap();
1018        test_utils::generate_wav(&one.join("Album/a.wav"), 44100, 1, 0.2, 16);
1019        test_utils::generate_wav(&one.join("b.wav"), 44100, 1, 0.3, 16);
1020        test_utils::generate_wav(&two.join("c.wav"), 44100, 1, 0.4, 16);
1021        test_utils::generate_wav(&three.join("d.wav"), 44100, 1, 0.5, 16);
1022        let db = test_db(dir.path());
1023        let folders = [one.clone(), two.clone(), three.clone()];
1024        scan_folders(&db, &folders, ScanOptions::default(), None, None);
1025        let before = tracks_with_uids(&db);
1026
1027        std::fs::rename(one.join("Album"), two.join("Album")).unwrap();
1028        // A new file in the last folder, whose batch will not commit.
1029        test_utils::generate_wav(&three.join("fail.wav"), 44100, 1, 0.6, 16);
1030        fail_commits_for(&db, "%fail.wav");
1031        let r = scan_folders(&db, &folders, ScanOptions::default(), None, None);
1032
1033        assert!(r.incomplete);
1034        assert_eq!((r.removed, r.moved), (0, 1), "{:?}", r.errors);
1035        let after = tracks_with_uids(&db);
1036        assert_eq!(
1037            before.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>(),
1038            after.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>()
1039        );
1040        assert!(after.iter().any(|t| t.2.ends_with("two/Album/a.wav")));
1041    }
1042
1043    /// A scan that cannot write still waits for its readers, so the next scan
1044    /// in the lane never overlaps them.
1045    #[test]
1046    fn a_failed_transaction_joins_the_readers() {
1047        let dir = tempfile::tempdir().unwrap();
1048        let music = dir.path().join("music");
1049        std::fs::create_dir_all(&music).unwrap();
1050        // More than the channel holds, so readers are parked when it fails.
1051        let files: Vec<PathBuf> = (0..CHUNK_SIZE * 12)
1052            .map(|i| {
1053                let path = music.join(format!("{i:03}.wav"));
1054                test_utils::generate_wav(&path, 8000, 1, 0.01, 16);
1055                path
1056            })
1057            .collect();
1058        let db = test_db(dir.path());
1059        // An open transaction makes the scan's own fail to begin.
1060        db.conn.execute_batch("BEGIN").unwrap();
1061
1062        // The reader holds a clone of the cancel flag until it is done.
1063        let cancel = Arc::new(AtomicBool::new(false));
1064        let opts = ScanOptions {
1065            cancel: Some(cancel.clone()),
1066            force: true,
1067            ..Default::default()
1068        };
1069        let mut result = ScanResult::default();
1070        index_files(
1071            &db,
1072            files,
1073            std::slice::from_ref(&music),
1074            &opts,
1075            None,
1076            &mut result,
1077            &music,
1078        );
1079        drop(opts);
1080
1081        assert!(result.incomplete);
1082        assert!(!result.cancelled);
1083        assert_eq!(result.errors.len(), 1);
1084        assert_eq!(
1085            Arc::strong_count(&cancel),
1086            1,
1087            "returned before the reader ended"
1088        );
1089    }
1090
1091    #[test]
1092    fn cancelling_a_scan_returns_with_readers_still_pending() {
1093        let dir = tempfile::tempdir().unwrap();
1094        let music_dir = dir.path().join("music");
1095        std::fs::create_dir_all(&music_dir).unwrap();
1096        // Far more than the channel holds (2 * CHUNK_SIZE), so readers are
1097        // parked on a full channel when the consumer stops.
1098        for i in 0..CHUNK_SIZE * 12 {
1099            test_utils::generate_wav(&music_dir.join(format!("{i:03}.wav")), 8000, 1, 0.01, 16);
1100        }
1101
1102        let (done_tx, done_rx) = crossbeam_channel::bounded(1);
1103        let root = dir.path().to_path_buf();
1104        std::thread::spawn(move || {
1105            let db = test_db(&root);
1106            let cancel = Arc::new(AtomicBool::new(false));
1107            let opts = ScanOptions {
1108                cancel: Some(cancel.clone()),
1109                ..Default::default()
1110            };
1111            let on_track = |_: ScanEvent| cancel.store(true, std::sync::atomic::Ordering::Relaxed);
1112            let result = scan_folder(&db, &music_dir, opts, Some(&on_track));
1113            done_tx.send(result).unwrap();
1114        });
1115
1116        let result = done_rx
1117            .recv_timeout(std::time::Duration::from_secs(30))
1118            .expect("cancelled scan never returned");
1119        assert!(result.cancelled);
1120        assert!(result.added < CHUNK_SIZE * 12);
1121    }
1122
1123    /// The folder watcher's scans carry no cancel flag of their own; the lane
1124    /// gives them one, which the app's Cancel reaches.
1125    #[test]
1126    fn a_scan_started_without_a_cancel_flag_can_still_be_cancelled() {
1127        let dir = tempfile::tempdir().unwrap();
1128        let music = dir.path().join("music");
1129        std::fs::create_dir_all(&music).unwrap();
1130        for i in 0..CHUNK_SIZE * 6 {
1131            test_utils::generate_wav(&music.join(format!("{i:03}.wav")), 8000, 1, 0.01, 16);
1132        }
1133        let db = test_db(dir.path());
1134        let on_track = |_: ScanEvent| super::super::lane::cancel_all();
1135        let result = scan_folder(&db, &music, ScanOptions::default(), Some(&on_track));
1136        assert!(result.cancelled);
1137        assert!(result.added < CHUNK_SIZE * 6);
1138    }
1139
1140    /// The readers see the cancel before any of them sends, so the consumer's
1141    /// first batch is empty: the scan is still cancelled, not finished.
1142    #[test]
1143    fn a_scan_cancelled_before_any_file_is_read_is_cancelled() {
1144        let dir = tempfile::tempdir().unwrap();
1145        let music = dir.path().join("music");
1146        std::fs::create_dir_all(&music).unwrap();
1147        for i in 0..CHUNK_SIZE * 2 {
1148            test_utils::generate_wav(&music.join(format!("{i:03}.wav")), 8000, 1, 0.01, 16);
1149        }
1150        let db = test_db(dir.path());
1151        let opts = ScanOptions {
1152            cancel: Some(Arc::new(AtomicBool::new(true))),
1153            ..Default::default()
1154        };
1155        let result = scan_folder(&db, &music, opts, None);
1156        assert!(result.cancelled);
1157        assert_eq!(result.added, 0);
1158    }
1159
1160    /// Forgetting a folder stops a scan of it, and only of it.
1161    #[test]
1162    fn a_scan_is_cancelled_by_forgetting_its_folder_only() {
1163        let dir = tempfile::tempdir().unwrap();
1164        let music = dir.path().join("music");
1165        std::fs::create_dir_all(&music).unwrap();
1166        for i in 0..CHUNK_SIZE * 6 {
1167            test_utils::generate_wav(&music.join(format!("{i:03}.wav")), 8000, 1, 0.01, 16);
1168        }
1169        let elsewhere = dir.path().join("elsewhere");
1170        let db = test_db(dir.path());
1171
1172        let on_track = |_: ScanEvent| super::super::lane::cancel_under(&elsewhere);
1173        let result = scan_folder(&db, &music, ScanOptions::default(), Some(&on_track));
1174        assert!(!result.cancelled);
1175        assert_eq!(result.added, CHUNK_SIZE * 6);
1176
1177        let album = music.join("Album");
1178        std::fs::create_dir_all(&album).unwrap();
1179        for i in 0..CHUNK_SIZE * 6 {
1180            test_utils::generate_wav(&album.join(format!("{i:03}.wav")), 8000, 1, 0.02, 16);
1181        }
1182        let on_track = |_: ScanEvent| super::super::lane::cancel_under(&album);
1183        let result = scan_folder(&db, &music, ScanOptions::default(), Some(&on_track));
1184        assert!(result.cancelled, "a scan of the folder it is in");
1185    }
1186
1187    /// Scans take turns: one waits while another holds the lane, and starts
1188    /// when it is let go.
1189    #[test]
1190    fn scans_wait_their_turn() {
1191        let dir = tempfile::tempdir().unwrap();
1192        let music = dir.path().join("music");
1193        std::fs::create_dir_all(&music).unwrap();
1194        test_utils::generate_wav(&music.join("a.wav"), 8000, 1, 0.1, 16);
1195        let db_root = dir.path().to_path_buf();
1196
1197        let held = super::super::lane::wait();
1198        let (done_tx, done_rx) = crossbeam_channel::bounded(1);
1199        std::thread::spawn(move || {
1200            let db = test_db(&db_root);
1201            done_tx
1202                .send(scan_folder(&db, &music, ScanOptions::default(), None).added)
1203                .unwrap();
1204        });
1205        assert!(
1206            done_rx
1207                .recv_timeout(std::time::Duration::from_millis(300))
1208                .is_err(),
1209            "ran while the lane was held"
1210        );
1211        drop(held);
1212        let added = done_rx
1213            .recv_timeout(std::time::Duration::from_secs(30))
1214            .expect("never ran once the lane was free");
1215        assert_eq!(added, 1);
1216    }
1217
1218    /// A drop is for playing now: it does not queue behind a scan, which on a
1219    /// large library can take minutes.
1220    #[test]
1221    fn an_import_does_not_wait_for_a_scan() {
1222        let dir = tempfile::tempdir().unwrap();
1223        let rip = dir.path().join("rip");
1224        std::fs::create_dir_all(&rip).unwrap();
1225        test_utils::generate_wav(&rip.join("a.wav"), 8000, 1, 0.1, 16);
1226        let db_root = dir.path().to_path_buf();
1227
1228        let mut opts = ScanOptions::default();
1229        let held = super::super::lane::enter(&[dir.path().join("library")], &mut opts);
1230        let (done_tx, done_rx) = crossbeam_channel::bounded(1);
1231        std::thread::spawn(move || {
1232            let db = test_db(&db_root);
1233            done_tx
1234                .send(
1235                    import_paths(&db, std::slice::from_ref(&rip))
1236                        .track_ids
1237                        .len(),
1238                )
1239                .unwrap();
1240        });
1241        let imported = done_rx
1242            .recv_timeout(std::time::Duration::from_secs(30))
1243            .expect("waited for the scan");
1244        assert_eq!(imported, 1);
1245        drop(held);
1246    }
1247
1248    #[test]
1249    fn scan_folder_indexes_new_files() {
1250        let dir = tempfile::tempdir().unwrap();
1251        let music_dir = dir.path().join("music");
1252        std::fs::create_dir_all(&music_dir).unwrap();
1253
1254        // Generate a valid WAV file (1 second, 44100 Hz, mono, 16-bit).
1255        let wav_path = music_dir.join("silence.wav");
1256        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
1257
1258        let db = test_db(dir.path());
1259        let result = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1260
1261        assert_eq!(result.added, 1, "expected 1 track added");
1262        assert_eq!(result.skipped, 0);
1263        assert_eq!(result.removed, 0);
1264        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
1265
1266        // Verify the track exists in the DB.
1267        let stats = queries::library_stats(&db.conn).unwrap();
1268        assert_eq!(stats.total_tracks, 1, "expected 1 track in DB");
1269    }
1270
1271    #[test]
1272    fn a_renamed_file_keeps_its_track() {
1273        let dir = tempfile::tempdir().unwrap();
1274        let music = dir.path().join("music");
1275        let album = music.join("Squire Of Gothos").join("Album");
1276        std::fs::create_dir_all(&album).unwrap();
1277        for i in 1..=3 {
1278            test_utils::generate_wav(
1279                &album.join(format!("0{i}. Squire Of Gothos - T{i}.wav")),
1280                44100,
1281                1,
1282                0.5 + i as f32 * 0.1,
1283                16,
1284            );
1285        }
1286        let db = test_db(dir.path());
1287        scan_folder(&db, &music, ScanOptions::default(), None);
1288        let before = tracks_with_uids(&db);
1289        let first = before[0].0;
1290        queries::record_play(&db.conn, queries::LOCAL_USER, first, Some(1000)).unwrap();
1291        queries::add_favourite(&db.conn, queries::LOCAL_USER, first).unwrap();
1292
1293        // What a retag that re-files does: the artist folder and file names
1294        // change case.
1295        let moved = music.join("The Squire of Gothos").join("Album");
1296        std::fs::create_dir_all(moved.parent().unwrap()).unwrap();
1297        std::fs::rename(&album, &moved).unwrap();
1298        std::fs::remove_dir(music.join("Squire Of Gothos")).unwrap();
1299        for i in 1..=3 {
1300            std::fs::rename(
1301                moved.join(format!("0{i}. Squire Of Gothos - T{i}.wav")),
1302                moved.join(format!("0{i}. The Squire of Gothos - T{i}.wav")),
1303            )
1304            .unwrap();
1305        }
1306        let result = scan_folder(&db, &music, ScanOptions::default(), None);
1307
1308        assert_eq!(
1309            (result.added, result.removed, result.moved),
1310            (0, 0, 3),
1311            "{:?}",
1312            result.errors
1313        );
1314        let paths = track_paths(&db);
1315        assert_eq!(paths.len(), 3, "{paths:#?}");
1316        assert!(paths.iter().all(|p| Path::new(p).exists()), "{paths:#?}");
1317        let after = tracks_with_uids(&db);
1318        assert_eq!(
1319            before.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>(),
1320            after.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>(),
1321            "same rows, same uids"
1322        );
1323        let kept: (i64, i64) = db
1324            .conn
1325            .query_row(
1326                "SELECT (SELECT COUNT(*) FROM play_history WHERE track_id = ?1),
1327                        (SELECT COUNT(*) FROM favourites WHERE track_id = ?1)",
1328                [first],
1329                |r| Ok((r.get(0)?, r.get(1)?)),
1330            )
1331            .unwrap();
1332        assert_eq!(kept, (1, 1), "history and favourite kept");
1333    }
1334
1335    /// Moved from one library folder to another: every folder is indexed
1336    /// before any is pruned, so the old path is not missed first.
1337    /// A library that already holds one file twice, under the spelling
1338    /// organize once stored and the directory's own, comes back to one track:
1339    /// the older, with its history. Only where the filesystem opens both
1340    /// spellings, which is the only place organize could have left them.
1341    #[cfg(target_os = "macos")]
1342    #[test]
1343    fn a_file_stored_under_two_spellings_is_folded_into_one_track() {
1344        use unicode_normalization::UnicodeNormalization;
1345        let dir = tempfile::tempdir().unwrap();
1346        let music = dir.path().join("music");
1347        let nfd: String = "Björk".nfd().collect();
1348        let nfc: String = "Björk".nfc().collect();
1349        std::fs::create_dir_all(music.join(&nfd)).unwrap();
1350        test_utils::generate_wav(&music.join(&nfd).join("Jóga.wav"), 44100, 1, 0.5, 16);
1351        let on_disk = music
1352            .join(&nfd)
1353            .join("Jóga.wav")
1354            .to_string_lossy()
1355            .into_owned();
1356        let organized = music
1357            .join(&nfc)
1358            .join("Jóga.wav")
1359            .to_string_lossy()
1360            .into_owned();
1361        let db = test_db(dir.path());
1362        scan_folder(&db, &music, ScanOptions::default(), None);
1363        let original = tracks_with_uids(&db)[0].0;
1364        queries::record_play(&db.conn, queries::LOCAL_USER, original, Some(1000)).unwrap();
1365        // What organize stored before it took the directory's spelling: every
1366        // path-keyed row, as `rewrite_path_references` writes them.
1367        queries::sources::rename_file(&db.conn, &on_disk, &organized).unwrap();
1368        for table in ["tracks", "scan_cache"] {
1369            db.conn
1370                .execute(
1371                    &format!("UPDATE {table} SET path = ?1 WHERE path = ?2"),
1372                    [&organized, &on_disk],
1373                )
1374                .unwrap();
1375        }
1376        // The scan that made the second row.
1377        scan_folder(&db, &music, ScanOptions::default(), None);
1378        scan_folder(&db, &music, ScanOptions::default(), None);
1379
1380        let tracks = tracks_with_uids(&db);
1381        assert_eq!(tracks.len(), 1, "{tracks:?}");
1382        assert_eq!(tracks[0].0, original);
1383        assert_eq!(track_paths(&db), vec![on_disk]);
1384        let plays: i64 = db
1385            .conn
1386            .query_row(
1387                "SELECT COUNT(*) FROM play_history WHERE track_id = ?1",
1388                [original],
1389                |r| r.get(0),
1390            )
1391            .unwrap();
1392        assert_eq!(plays, 1);
1393    }
1394
1395    /// A library folder configured in another case than the disk's, whose
1396    /// rows an earlier build stored under the configured spelling, keeps one
1397    /// track per file once scans walk the disk's: the rows move across, and a
1398    /// file already indexed under both is folded into its original track.
1399    #[cfg(target_os = "macos")]
1400    #[test]
1401    fn rows_stored_under_a_library_folder_in_another_case_move_to_the_disks() {
1402        let dir = tempfile::tempdir().unwrap();
1403        let music = dir.path().join("Music");
1404        let given = dir.path().join("music");
1405        std::fs::create_dir_all(music.join("Artist")).unwrap();
1406        if !given.exists() {
1407            return;
1408        }
1409        for name in ["a.wav", "b.wav"] {
1410            test_utils::generate_wav(&music.join("Artist").join(name), 44100, 1, 0.2, 16);
1411        }
1412        let db = test_db(dir.path());
1413        scan_folder(&db, &music, ScanOptions::default(), None);
1414        let original: Vec<i64> = tracks_with_uids(&db).iter().map(|t| t.0).collect();
1415        // As an earlier build stored them, scanning the folder as configured.
1416        let (from, to) = (
1417            format!("{}/", music.display()),
1418            format!("{}/", given.display()),
1419        );
1420        for (table, column) in [
1421            ("tracks", "path"),
1422            ("local_files", "path"),
1423            ("scan_cache", "path"),
1424        ] {
1425            db.conn
1426                .execute(
1427                    &format!("UPDATE {table} SET {column} = replace({column}, ?1, ?2)"),
1428                    [&from, &to],
1429                )
1430                .unwrap();
1431        }
1432        // And one file indexed a second time under the disk's spelling.
1433        let twin = music.join("Artist/b.wav");
1434        import_paths(&db, std::slice::from_ref(&twin));
1435        assert_eq!(tracks_with_uids(&db).len(), 3);
1436
1437        scan_folder(&db, &given, ScanOptions::default(), None);
1438
1439        let tracks = tracks_with_uids(&db);
1440        assert_eq!(
1441            tracks.iter().map(|t| t.0).collect::<Vec<_>>(),
1442            original,
1443            "{tracks:?}"
1444        );
1445        assert!(
1446            track_paths(&db).iter().all(|p| p.starts_with(&from)),
1447            "{:?}",
1448            track_paths(&db)
1449        );
1450        let stale: i64 = db
1451            .conn
1452            .query_row(
1453                "SELECT (SELECT COUNT(*) FROM local_files WHERE substr(path, 1, length(?1)) = ?1)
1454                      + (SELECT COUNT(*) FROM scan_cache WHERE substr(path, 1, length(?1)) = ?1)",
1455                [&to],
1456                |r| r.get(0),
1457            )
1458            .unwrap();
1459        assert_eq!(stale, 0);
1460    }
1461
1462    /// The rarer leftovers of a library scanned under both spellings: a file
1463    /// two server tracks claimed, which cannot be folded, goes to the track
1464    /// under the disk's spelling; a playlist file read twice keeps its first
1465    /// playlist.
1466    #[cfg(target_os = "macos")]
1467    #[test]
1468    fn respelling_a_folder_leaves_nothing_under_the_old_spelling() {
1469        let dir = tempfile::tempdir().unwrap();
1470        let music = dir.path().join("Music");
1471        let given = dir.path().join("music");
1472        std::fs::create_dir_all(&music).unwrap();
1473        if !given.exists() {
1474            return;
1475        }
1476        let file = music.join("b.wav");
1477        test_utils::generate_wav(&file, 8000, 1, 0.1, 16);
1478        let db = test_db(dir.path());
1479        let mut tracks = Vec::new();
1480        for (root, remote) in [(&given, "sub-1"), (&music, "sub-2")] {
1481            let mut meta = metadata::read_metadata(&file).unwrap();
1482            meta.path = Some(root.join("b.wav").to_string_lossy().into_owned());
1483            meta.remote_id = Some(remote.into());
1484            tracks.push(queries::upsert_track(&db.conn, &meta).unwrap());
1485        }
1486        let local_files = |db: &Database| -> Vec<(i64, String)> {
1487            db.conn
1488                .prepare("SELECT track_id, path FROM local_files ORDER BY path")
1489                .unwrap()
1490                .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
1491                .unwrap()
1492                .collect::<Result<_, _>>()
1493                .unwrap()
1494        };
1495        assert_eq!(local_files(&db).len(), 2, "two tracks, one file each");
1496        let playlists: Vec<i64> = [&given, &music]
1497            .iter()
1498            .map(|root| {
1499                let id =
1500                    queries::create_playlist(&db.conn, queries::LOCAL_USER, "list", None).unwrap();
1501                db.conn
1502                    .execute(
1503                        "UPDATE playlists SET source_path = ?1 WHERE id = ?2",
1504                        rusqlite::params![root.join("list.nsp").to_string_lossy(), id],
1505                    )
1506                    .unwrap();
1507                id
1508            })
1509            .collect();
1510
1511        queries::sources::respell_folder(&db.conn, &given, &music).unwrap();
1512
1513        assert_eq!(
1514            local_files(&db),
1515            vec![(tracks[1], file.to_string_lossy().into_owned())]
1516        );
1517        let remaining: i64 = db
1518            .conn
1519            .query_row(
1520                "SELECT COUNT(*) FROM tracks WHERE id IN (?1, ?2)",
1521                [tracks[0], tracks[1]],
1522                |r| r.get(0),
1523            )
1524            .unwrap();
1525        assert_eq!(remaining, 2, "both stay as the server's");
1526        let kept: Vec<(i64, String)> = db
1527            .conn
1528            .prepare("SELECT id, source_path FROM playlists WHERE source_path IS NOT NULL")
1529            .unwrap()
1530            .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
1531            .unwrap()
1532            .collect::<Result<_, _>>()
1533            .unwrap();
1534        assert_eq!(
1535            kept,
1536            vec![(
1537                playlists[0],
1538                music.join("list.nsp").to_string_lossy().into_owned()
1539            )]
1540        );
1541    }
1542
1543    #[test]
1544    fn a_file_moved_between_library_folders_keeps_its_track() {
1545        let dir = tempfile::tempdir().unwrap();
1546        let (one, two) = (dir.path().join("one"), dir.path().join("two"));
1547        std::fs::create_dir_all(one.join("Album")).unwrap();
1548        std::fs::create_dir_all(&two).unwrap();
1549        test_utils::generate_wav(&one.join("Album/a.wav"), 44100, 1, 0.2, 16);
1550        test_utils::generate_wav(&one.join("b.wav"), 44100, 1, 0.3, 16);
1551        test_utils::generate_wav(&two.join("c.wav"), 44100, 1, 0.4, 16);
1552        let db = test_db(dir.path());
1553        let folders = [one.clone(), two.clone()];
1554        scan_folders(&db, &folders, ScanOptions::default(), None, None);
1555        let before = tracks_with_uids(&db);
1556
1557        std::fs::rename(one.join("Album"), two.join("Album")).unwrap();
1558        let r = scan_folders(&db, &folders, ScanOptions::default(), None, None);
1559        assert_eq!((r.added, r.removed, r.moved), (0, 0, 1), "{:?}", r.errors);
1560        let after = tracks_with_uids(&db);
1561        assert_eq!(
1562            before.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>(),
1563            after.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>()
1564        );
1565        assert!(after.iter().any(|t| t.2.ends_with("two/Album/a.wav")));
1566    }
1567
1568    /// Track ids, uids and paths, by id.
1569    fn tracks_with_uids(db: &Database) -> Vec<(i64, String, String)> {
1570        db.conn
1571            .prepare("SELECT id, uid, path FROM tracks ORDER BY id")
1572            .unwrap()
1573            .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
1574            .unwrap()
1575            .collect::<Result<_, _>>()
1576            .unwrap()
1577    }
1578
1579    fn track_paths(db: &Database) -> Vec<String> {
1580        db.conn
1581            .prepare("SELECT path FROM tracks ORDER BY path")
1582            .unwrap()
1583            .query_map([], |r| r.get(0))
1584            .unwrap()
1585            .collect::<Result<_, _>>()
1586            .unwrap()
1587    }
1588
1589    /// A track also on the server outlives its folder being forgotten, as the
1590    /// server's copy. Added back, the folder's file must be read again.
1591    #[test]
1592    fn a_forgotten_folder_added_back_is_read_again() {
1593        let dir = tempfile::tempdir().unwrap();
1594        let music = dir.path().join("music");
1595        std::fs::create_dir_all(&music).unwrap();
1596        let file = music.join("kept.wav");
1597        test_utils::generate_wav(&file, 8000, 1, 0.1, 16);
1598        let db = test_db(dir.path());
1599        let mut both = metadata::read_metadata(&file).unwrap();
1600        both.remote_id = Some("sub-1".into());
1601        let track = queries::upsert_track(&db.conn, &both).unwrap();
1602        let local = |db: &Database| -> Option<String> {
1603            db.conn
1604                .query_row("SELECT path FROM tracks WHERE id = ?1", [track], |r| {
1605                    r.get(0)
1606                })
1607                .unwrap()
1608        };
1609        scan_folder(&db, &music, ScanOptions::default(), None);
1610
1611        assert_eq!(crate::helpers::forget_folder(&db, &music).unwrap(), 1);
1612        assert_eq!(local(&db), None, "kept as the server's copy");
1613
1614        let again = scan_folder(&db, &music, ScanOptions::default(), None);
1615        assert_eq!(again.skipped, 0, "{:?}", again.errors);
1616        assert_eq!(local(&db), Some(file.to_string_lossy().into_owned()));
1617    }
1618
1619    #[test]
1620    fn scans_skip_what_the_watcher_ignores() {
1621        let dir = tempfile::tempdir().unwrap();
1622        let music = dir.path().join("music");
1623        let album = music.join("Artist/Album");
1624        let dotted = music.join("Britney Spears/...Baby One More Time");
1625        for sub in [
1626            album.join(".stversions"),
1627            album.join("Disc 2.part"),
1628            dotted.clone(),
1629        ] {
1630            std::fs::create_dir_all(sub).unwrap();
1631        }
1632        let wav = |p: PathBuf| test_utils::generate_wav(&p, 8000, 1, 0.1, 16);
1633        wav(album.join("01.wav"));
1634        wav(dotted.join("01.wav"));
1635        wav(album.join(".stversions/01~20261006-120000.wav"));
1636        wav(album.join("._01.wav"));
1637        wav(album.join("Disc 2.part/02.wav"));
1638        wav(album.join("~syncthing~03.wav"));
1639        let db = test_db(dir.path());
1640
1641        let full = scan_folder(&db, &music, ScanOptions::default(), None);
1642        assert_eq!(full.added, 2, "{:?}", full.errors);
1643        let dirs = scan_dirs(
1644            &db,
1645            std::slice::from_ref(&music),
1646            std::slice::from_ref(&album),
1647            ScanOptions::default(),
1648            None,
1649        );
1650        assert_eq!(dirs.added, 0, "{:?}", dirs.errors);
1651        assert_eq!(
1652            track_paths(&db),
1653            [album.join("01.wav"), dotted.join("01.wav")].map(|p| p.to_string_lossy().into_owned())
1654        );
1655    }
1656
1657    #[test]
1658    fn minimal_dirs_keeps_only_the_outermost() {
1659        let dirs = [
1660            "/m/A/Album 2",
1661            "/m/A/Album",
1662            "/m/A/Album/CD1",
1663            "/m/A/Album",
1664            "/m/AB",
1665            "/m/B/Album/CD2",
1666        ]
1667        .map(PathBuf::from)
1668        .to_vec();
1669        assert_eq!(
1670            minimal_dirs(dirs),
1671            ["/m/A/Album", "/m/A/Album 2", "/m/AB", "/m/B/Album/CD2"].map(PathBuf::from)
1672        );
1673        assert_eq!(
1674            minimal_dirs(["/m/A/x", "/m/A"].map(PathBuf::from).to_vec()),
1675            [PathBuf::from("/m/A")]
1676        );
1677    }
1678
1679    #[test]
1680    fn scan_dirs_covers_only_the_directories_named() {
1681        let dir = tempfile::tempdir().unwrap();
1682        let music = dir.path().join("music");
1683        let (old, new) = (music.join("Old"), music.join("New"));
1684        std::fs::create_dir_all(&old).unwrap();
1685        std::fs::create_dir_all(&new).unwrap();
1686        test_utils::generate_wav(&old.join("a.wav"), 44100, 1, 0.2, 16);
1687
1688        let db = test_db(dir.path());
1689        scan_folder(&db, &music, ScanOptions::default(), None);
1690        test_utils::generate_wav(&new.join("b.wav"), 44100, 1, 0.2, 16);
1691        // Not named, so not seen: this scan is not a walk of the library.
1692        test_utils::generate_wav(&old.join("c.wav"), 44100, 1, 0.3, 16);
1693
1694        let r = scan_dirs(
1695            &db,
1696            std::slice::from_ref(&music),
1697            std::slice::from_ref(&new),
1698            ScanOptions::default(),
1699            None,
1700        );
1701        assert_eq!((r.added, r.skipped, r.removed), (1, 0, 0), "{:?}", r.errors);
1702        assert_eq!(track_paths(&db).len(), 2);
1703    }
1704
1705    #[test]
1706    fn rescanning_a_directory_tells_the_apps_its_art_may_have_changed() {
1707        let dir = tempfile::tempdir().unwrap();
1708        let music = dir.path().join("music");
1709        let (album, other) = (music.join("Album"), music.join("Other"));
1710        std::fs::create_dir_all(album.join("CD1")).unwrap();
1711        std::fs::create_dir_all(&other).unwrap();
1712        test_utils::generate_wav(&album.join("CD1/a.wav"), 44100, 1, 0.2, 16);
1713        test_utils::generate_wav(&other.join("b.wav"), 44100, 1, 0.2, 16);
1714        let db = test_db(dir.path());
1715        scan_folder(&db, &music, ScanOptions::default(), None);
1716        let evicted = |db: &Database| -> Vec<(String, i64)> {
1717            db.conn
1718                .prepare("SELECT kind, id FROM art_evictions ORDER BY seq")
1719                .unwrap()
1720                .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
1721                .unwrap()
1722                .collect::<Result<_, _>>()
1723                .unwrap()
1724        };
1725        assert!(evicted(&db).is_empty());
1726
1727        // The watcher names the album's folder when a cover lands in it.
1728        std::fs::write(album.join("cover.jpg"), b"").unwrap();
1729        scan_dirs(
1730            &db,
1731            std::slice::from_ref(&music),
1732            std::slice::from_ref(&album),
1733            ScanOptions::default(),
1734            None,
1735        );
1736        let (album_id, track_id): (i64, i64) = db
1737            .conn
1738            .query_row(
1739                "SELECT album_id, id FROM tracks WHERE path LIKE '%/CD1/a.wav'",
1740                [],
1741                |r| Ok((r.get(0)?, r.get(1)?)),
1742            )
1743            .unwrap();
1744        assert_eq!(
1745            evicted(&db),
1746            vec![("album".into(), album_id), ("track".into(), track_id)],
1747            "only the records under the folder rescanned"
1748        );
1749    }
1750
1751    #[test]
1752    fn scan_dirs_removes_what_left_a_directory_and_a_directory_that_left() {
1753        let dir = tempfile::tempdir().unwrap();
1754        let music = dir.path().join("music");
1755        let (kept, gone) = (music.join("Artist/Kept"), music.join("Artist/Gone"));
1756        std::fs::create_dir_all(&kept).unwrap();
1757        std::fs::create_dir_all(&gone).unwrap();
1758        for i in 0..3 {
1759            test_utils::generate_wav(&kept.join(format!("{i}.wav")), 44100, 1, 0.2, 16);
1760            test_utils::generate_wav(&gone.join(format!("{i}.wav")), 44100, 1, 0.3, 16);
1761        }
1762        let db = test_db(dir.path());
1763        assert_eq!(
1764            scan_folder(&db, &music, ScanOptions::default(), None).added,
1765            6
1766        );
1767
1768        std::fs::remove_file(kept.join("0.wav")).unwrap();
1769        std::fs::remove_dir_all(&gone).unwrap();
1770        let r = scan_dirs(
1771            &db,
1772            std::slice::from_ref(&music),
1773            &[kept.clone(), gone.clone()],
1774            ScanOptions::default(),
1775            None,
1776        );
1777
1778        assert_eq!((r.removed, r.skipped), (4, 2), "{:?}", r.errors);
1779        let paths = track_paths(&db);
1780        assert_eq!(paths.len(), 2, "{paths:#?}");
1781        assert!(paths.iter().all(|p| Path::new(p).exists()), "{paths:#?}");
1782    }
1783
1784    /// An unmounted volume leaves an empty mount point, under which every
1785    /// directory reads as deleted.
1786    #[test]
1787    fn scan_dirs_removes_nothing_under_an_empty_library_folder() {
1788        let dir = tempfile::tempdir().unwrap();
1789        let music = dir.path().join("music");
1790        let album = music.join("Album");
1791        std::fs::create_dir_all(&album).unwrap();
1792        test_utils::generate_wav(&album.join("a.wav"), 44100, 1, 0.2, 16);
1793        let db = test_db(dir.path());
1794        scan_folder(&db, &music, ScanOptions::default(), None);
1795
1796        std::fs::remove_dir_all(&album).unwrap();
1797        let r = scan_dirs(
1798            &db,
1799            std::slice::from_ref(&music),
1800            std::slice::from_ref(&album),
1801            ScanOptions::default(),
1802            None,
1803        );
1804        assert_eq!(r.removed, 0);
1805        assert_eq!(track_paths(&db).len(), 1);
1806    }
1807
1808    /// A share linked into the library and gone away leaves a dangling
1809    /// symlink, through which every file reads as deleted. Neither a full
1810    /// scan nor the watcher's forced rescan may take that as a deletion.
1811    #[cfg(unix)]
1812    #[test]
1813    fn a_dangling_symlink_is_not_a_deletion() {
1814        let dir = tempfile::tempdir().unwrap();
1815        let music = dir.path().join("music");
1816        let local = music.join("Local");
1817        let share = dir.path().join("share");
1818        std::fs::create_dir_all(&local).unwrap();
1819        std::fs::create_dir_all(share.join("Album")).unwrap();
1820        test_utils::generate_wav(&local.join("a.wav"), 44100, 1, 0.2, 16);
1821        test_utils::generate_wav(&share.join("Album/b.wav"), 44100, 1, 0.2, 16);
1822        std::os::unix::fs::symlink(&share, music.join("nas")).unwrap();
1823        let db = test_db(dir.path());
1824        assert_eq!(
1825            scan_folder(&db, &music, ScanOptions::default(), None).added,
1826            2
1827        );
1828
1829        std::fs::remove_dir_all(&share).unwrap();
1830        let full = scan_folder(&db, &music, ScanOptions::default(), None);
1831        assert_eq!(full.removed, 0, "{:?}", full.removed_paths);
1832        let watched = scan_dirs(
1833            &db,
1834            std::slice::from_ref(&music),
1835            &[music.join("nas/Album")],
1836            ScanOptions::default(),
1837            None,
1838        );
1839        assert_eq!(watched.removed, 0, "{:?}", watched.removed_paths);
1840        assert_eq!(track_paths(&db).len(), 2);
1841
1842        // The link itself removed is a deletion like any other.
1843        std::fs::remove_file(music.join("nas")).unwrap();
1844        let removed = scan_folder(&db, &music, ScanOptions::default(), None);
1845        assert_eq!(removed.removed, 1);
1846    }
1847
1848    /// A name that is not UTF-8 would be stored under a different one, which
1849    /// no stat finds: the same scan would add it and remove it.
1850    #[cfg(target_os = "linux")]
1851    #[test]
1852    fn a_path_that_is_not_utf8_is_skipped() {
1853        use std::os::unix::ffi::OsStrExt;
1854        let dir = tempfile::tempdir().unwrap();
1855        let music = dir.path().join("music");
1856        std::fs::create_dir_all(&music).unwrap();
1857        test_utils::generate_wav(&music.join("a.wav"), 44100, 1, 0.2, 16);
1858        let odd = music.join(std::ffi::OsStr::from_bytes(b"caf\xe9.wav"));
1859        test_utils::generate_wav(&odd, 44100, 1, 0.2, 16);
1860        let db = test_db(dir.path());
1861
1862        let r = scan_folder(&db, &music, ScanOptions::default(), None);
1863        assert_eq!((r.added, r.removed), (1, 0));
1864        assert_eq!(track_paths(&db).len(), 1);
1865    }
1866
1867    #[test]
1868    fn scan_dirs_ignores_directories_outside_the_library() {
1869        let dir = tempfile::tempdir().unwrap();
1870        let music = dir.path().join("music");
1871        let elsewhere = dir.path().join("elsewhere");
1872        std::fs::create_dir_all(&music).unwrap();
1873        std::fs::create_dir_all(&elsewhere).unwrap();
1874        test_utils::generate_wav(&elsewhere.join("a.wav"), 44100, 1, 0.2, 16);
1875        let db = test_db(dir.path());
1876
1877        let r = scan_dirs(
1878            &db,
1879            std::slice::from_ref(&music),
1880            std::slice::from_ref(&elsewhere),
1881            ScanOptions::default(),
1882            None,
1883        );
1884        assert_eq!(r.added, 0);
1885        assert!(track_paths(&db).is_empty());
1886    }
1887
1888    #[test]
1889    fn import_paths_indexes_files_where_they_lie() {
1890        let dir = tempfile::tempdir().unwrap();
1891        // Deliberately nothing to do with a library folder — this is the
1892        // drag-a-rip-onto-the-queue case.
1893        let drop = dir.path().join("Downloads/rip");
1894        std::fs::create_dir_all(&drop).unwrap();
1895        test_utils::generate_wav(&drop.join("01.wav"), 44100, 1, 0.2, 16);
1896        test_utils::generate_wav(&drop.join("02.wav"), 44100, 1, 0.2, 16);
1897        std::fs::write(drop.join("notes.txt"), b"not music").unwrap();
1898
1899        let db = test_db(dir.path());
1900        let result = import_paths(&db, std::slice::from_ref(&drop));
1901
1902        assert_eq!(result.added, 2);
1903        assert_eq!(result.track_ids.len(), 2, "errors: {:?}", result.errors);
1904        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
1905
1906        // The rows point at where the files still are; organize is what moves them.
1907        for id in &result.track_ids {
1908            let row = queries::get_track_row(&db.conn, *id).unwrap().unwrap();
1909            assert!(row.path.unwrap().starts_with(drop.to_str().unwrap()));
1910        }
1911    }
1912
1913    /// Dropping the same rip twice queues it again without duplicating rows.
1914    #[test]
1915    fn import_paths_is_idempotent() {
1916        let dir = tempfile::tempdir().unwrap();
1917        let drop = dir.path().join("rip");
1918        std::fs::create_dir_all(&drop).unwrap();
1919        test_utils::generate_wav(&drop.join("a.wav"), 44100, 1, 0.2, 16);
1920
1921        let db = test_db(dir.path());
1922        let first = import_paths(&db, std::slice::from_ref(&drop));
1923        let second = import_paths(&db, std::slice::from_ref(&drop));
1924
1925        assert_eq!(first.added, 1);
1926        // Through the scan cache: unchanged, so not read again, and still queued.
1927        assert_eq!((second.added, second.updated), (0, 0));
1928        assert_eq!(first.track_ids, second.track_ids);
1929        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
1930    }
1931
1932    /// A drop can name a folder and a file inside it; the file is imported once.
1933    #[test]
1934    fn import_paths_deduplicates_overlapping_selections() {
1935        let dir = tempfile::tempdir().unwrap();
1936        let drop = dir.path().join("rip");
1937        std::fs::create_dir_all(&drop).unwrap();
1938        let track = drop.join("a.wav");
1939        test_utils::generate_wav(&track, 44100, 1, 0.2, 16);
1940
1941        let db = test_db(dir.path());
1942        let result = import_paths(&db, &[drop.clone(), track.clone()]);
1943
1944        assert_eq!(result.track_ids.len(), 1);
1945    }
1946
1947    #[test]
1948    fn scan_folder_skips_unchanged_files() {
1949        let dir = tempfile::tempdir().unwrap();
1950        let music_dir = dir.path().join("music");
1951        std::fs::create_dir_all(&music_dir).unwrap();
1952
1953        let wav_path = music_dir.join("unchanged.wav");
1954        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
1955
1956        let db = test_db(dir.path());
1957
1958        // First scan: adds the file.
1959        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1960        assert_eq!(r1.added, 1);
1961
1962        // Second scan: file unchanged, should be skipped.
1963        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1964        assert_eq!(r2.skipped, 1, "expected unchanged file to be skipped");
1965        assert_eq!(r2.added, 0, "no new files should be added");
1966    }
1967
1968    #[test]
1969    fn a_forced_scan_reads_unchanged_files_again() {
1970        let dir = tempfile::tempdir().unwrap();
1971        let music_dir = dir.path().join("music");
1972        std::fs::create_dir_all(&music_dir).unwrap();
1973        test_utils::generate_wav(&music_dir.join("unchanged.wav"), 44100, 1, 1.0, 16);
1974        let db = test_db(dir.path());
1975        assert_eq!(
1976            scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
1977            1
1978        );
1979
1980        let forced = ScanOptions {
1981            force: true,
1982            ..Default::default()
1983        };
1984        let r = scan_folder(&db, &music_dir, forced, None);
1985        assert_eq!(r.skipped, 0, "the cache is not consulted: {:?}", r.errors);
1986        assert_eq!(
1987            (r.added, r.updated),
1988            (0, 1),
1989            "the file is read again, not added twice"
1990        );
1991    }
1992
1993    #[test]
1994    fn scan_folder_removes_deleted_tracks() {
1995        let dir = tempfile::tempdir().unwrap();
1996        let music_dir = dir.path().join("music");
1997        std::fs::create_dir_all(&music_dir).unwrap();
1998
1999        let wav_path = music_dir.join("ephemeral.wav");
2000        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
2001        test_utils::generate_wav(&music_dir.join("keeper.wav"), 44100, 1, 1.0, 16);
2002
2003        let db = test_db(dir.path());
2004
2005        // First scan: adds both files.
2006        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
2007        assert_eq!(r1.added, 2);
2008
2009        // Delete one of them.
2010        std::fs::remove_file(&wav_path).unwrap();
2011
2012        // Second scan: should detect removal.
2013        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
2014        assert_eq!(
2015            r2.removed, 1,
2016            "expected 1 track removed after file deletion"
2017        );
2018
2019        let stats = queries::library_stats(&db.conn).unwrap();
2020        assert_eq!(stats.total_tracks, 1, "the surviving file must be kept");
2021    }
2022
2023    #[test]
2024    fn empty_folder_does_not_wipe_the_library() {
2025        let dir = tempfile::tempdir().unwrap();
2026        let music_dir = dir.path().join("music");
2027        std::fs::create_dir_all(&music_dir).unwrap();
2028        test_utils::generate_wav(&music_dir.join("a.wav"), 44100, 1, 1.0, 16);
2029        test_utils::generate_wav(&music_dir.join("b.wav"), 44100, 1, 1.0, 16);
2030
2031        let db = test_db(dir.path());
2032        assert_eq!(
2033            scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
2034            2
2035        );
2036
2037        // The folder is still there but yields nothing — an unmounted NAS, a
2038        // detached volume, a Docker volume that failed to attach.
2039        std::fs::remove_file(music_dir.join("a.wav")).unwrap();
2040        std::fs::remove_file(music_dir.join("b.wav")).unwrap();
2041
2042        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
2043        assert_eq!(r.removed, 0, "stale removal must be skipped entirely");
2044        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
2045    }
2046
2047    #[cfg(unix)]
2048    #[test]
2049    fn unreadable_folder_is_not_a_deletion() {
2050        use std::os::unix::fs::PermissionsExt;
2051
2052        let dir = tempfile::tempdir().unwrap();
2053        let music_dir = dir.path().join("music");
2054        let locked_dir = music_dir.join("locked");
2055        std::fs::create_dir_all(&locked_dir).unwrap();
2056        test_utils::generate_wav(&music_dir.join("keep.wav"), 44100, 1, 1.0, 16);
2057        let locked_file = locked_dir.join("locked.wav");
2058        test_utils::generate_wav(&locked_file, 44100, 1, 1.0, 16);
2059
2060        let db = test_db(dir.path());
2061        assert_eq!(
2062            scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
2063            2
2064        );
2065
2066        std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
2067        if locked_file.try_exists().is_ok() {
2068            // Running as root — the permission bits mean nothing here.
2069            std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
2070            return;
2071        }
2072
2073        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
2074        std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
2075
2076        assert!(
2077            r.unreadable >= 1,
2078            "the unreadable subtree should be counted"
2079        );
2080        assert_eq!(r.removed, 0, "an IO error is not a deletion");
2081        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
2082    }
2083
2084    #[test]
2085    fn interrupted_scan_keeps_committed_chunks_and_resumes() {
2086        let dir = tempfile::tempdir().unwrap();
2087        let music_dir = dir.path().join("music");
2088        std::fs::create_dir_all(&music_dir).unwrap();
2089        for i in 0..6 {
2090            test_utils::generate_wav(&music_dir.join(format!("{}.wav", i)), 44100, 1, 1.0, 16);
2091        }
2092
2093        let db = test_db(dir.path());
2094
2095        // Abort once the first chunk is in, the way Ctrl-C would: tracks are
2096        // reported only after their chunk commits.
2097        let abort = |_: ScanEvent| panic!("simulated interrupt");
2098        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2099            scan_folder(&db, &music_dir, ScanOptions::default(), Some(&abort));
2100        }));
2101        assert!(panicked.is_err());
2102
2103        // The first chunk is on disk; the next is not.
2104        assert_eq!(
2105            queries::library_stats(&db.conn).unwrap().total_tracks,
2106            CHUNK_SIZE as i64
2107        );
2108
2109        // And the next run picks up where it left off instead of restarting.
2110        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
2111        assert_eq!(r.skipped, CHUNK_SIZE, "committed files should be cached");
2112        assert_eq!(r.added, 6 - CHUNK_SIZE);
2113        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 6);
2114    }
2115
2116    #[test]
2117    fn failing_file_is_named_and_the_scan_continues() {
2118        let dir = tempfile::tempdir().unwrap();
2119        let music_dir = dir.path().join("music");
2120        std::fs::create_dir_all(&music_dir).unwrap();
2121        test_utils::generate_wav(&music_dir.join("good.wav"), 44100, 1, 1.0, 16);
2122        let broken = music_dir.join("broken.flac");
2123        std::fs::write(&broken, b"").unwrap();
2124
2125        let db = test_db(dir.path());
2126        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
2127
2128        assert_eq!(r.added, 1, "the good file must still be indexed");
2129        assert_eq!(r.errors.len(), 1);
2130        assert_eq!(r.errors[0].0, broken, "the failing file must be named");
2131        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
2132    }
2133
2134    #[test]
2135    fn a_panicking_tag_read_becomes_an_error() {
2136        let path = Path::new("/music/hostile.mp3");
2137        let err = isolate_read(path, |_| panic!("bogus ID3v2 frame size")).unwrap_err();
2138        assert!(err.contains("hostile.mp3"), "should name the file: {}", err);
2139    }
2140
2141    #[test]
2142    fn scan_folder_updates_modified_files() {
2143        let dir = tempfile::tempdir().unwrap();
2144        let music_dir = dir.path().join("music");
2145        std::fs::create_dir_all(&music_dir).unwrap();
2146
2147        let wav_path = music_dir.join("modified.wav");
2148        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
2149
2150        let db = test_db(dir.path());
2151
2152        // First scan.
2153        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
2154        assert_eq!(r1.added, 1);
2155
2156        // Modify the file (rewrite with different duration → different size + mtime).
2157        // Sleep briefly to ensure mtime changes (some FS have 1s resolution).
2158        std::thread::sleep(std::time::Duration::from_millis(1100));
2159        test_utils::generate_wav(&wav_path, 44100, 1, 2.0, 16);
2160
2161        // Second scan: should detect the modification.
2162        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
2163        assert_eq!(r2.updated, 1, "modified file should be re-indexed");
2164        assert_eq!(r2.added, 0, "the row already exists");
2165        assert_eq!(r2.skipped, 0, "modified file should not be skipped");
2166    }
2167
2168    #[test]
2169    fn a_drop_and_a_scan_spell_a_file_the_same_way() {
2170        use unicode_normalization::UnicodeNormalization;
2171        let dir = tempfile::tempdir().unwrap();
2172        let nfd: String = "Roman Flügel".nfd().collect();
2173        let nfc: String = "Roman Flügel".nfc().collect();
2174        let music_dir = dir.path().join(&nfd);
2175        std::fs::create_dir_all(&music_dir).unwrap();
2176        test_utils::generate_wav(&music_dir.join("softice.wav"), 44100, 1, 0.2, 16);
2177        // Where the filesystem tells the two spellings apart, as Linux's do,
2178        // the precomposed path names no file, and there is nothing to drop.
2179        if !dir.path().join(&nfc).exists() {
2180            return;
2181        }
2182        let db = test_db(dir.path());
2183
2184        // Dropped from Finder: the path arrives precomposed.
2185        let dropped = import_paths(&db, &[dir.path().join(&nfc).join("softice.wav")]);
2186        assert_eq!(dropped.added, 1, "errors: {:?}", dropped.errors);
2187
2188        // Rescanned from the folder: the walker reads the directory's own bytes.
2189        let scanned = scan_folder(&db, &music_dir, ScanOptions::default(), None);
2190        assert_eq!(scanned.added, 0, "the same file, not a second one");
2191
2192        let stats = queries::library_stats(&db.conn).unwrap();
2193        assert_eq!(stats.total_tracks, 1);
2194        let stored: String = db
2195            .conn
2196            .query_row("SELECT path FROM tracks", [], |r| r.get(0))
2197            .unwrap();
2198        assert_eq!(stored, music_dir.join("softice.wav").to_string_lossy());
2199    }
2200}