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