|
1 | 1 | pub(crate) mod database; |
2 | 2 | mod decode; |
3 | 3 | mod discover; |
| 4 | +mod disk; |
4 | 5 | mod record; |
5 | 6 |
|
6 | 7 | use std::{ |
@@ -234,6 +235,38 @@ async fn resolve_missing_folder_action( |
234 | 235 | } |
235 | 236 | } |
236 | 237 |
|
| 238 | +/// Shared metadata reader loop used by both normal and slow-disk scanning modes. |
| 239 | +/// The caller provides a `recv` closure that abstracts over how paths are received |
| 240 | +/// (direct receiver vs. mutex-guarded shared receiver). |
| 241 | +fn run_metadata_reader( |
| 242 | + mut recv: impl FnMut() -> Option<(Utf8PathBuf, SystemTime)>, |
| 243 | + meta_tx: Sender<(Utf8PathBuf, SystemTime, FileInformation)>, |
| 244 | + decode_fail_tx: Sender<(Utf8PathBuf, SystemTime)>, |
| 245 | + cancel_flag: Arc<AtomicBool>, |
| 246 | +) { |
| 247 | + let mut art_cache: FxHashMap<Utf8PathBuf, Option<Arc<[u8]>>> = FxHashMap::default(); |
| 248 | + loop { |
| 249 | + if cancel_flag.load(Ordering::Relaxed) { |
| 250 | + break; |
| 251 | + } |
| 252 | + |
| 253 | + let Some((path, timestamp)) = recv() else { |
| 254 | + break; |
| 255 | + }; |
| 256 | + |
| 257 | + if let Some(info) = read_metadata_for_path(&path, &mut art_cache) { |
| 258 | + if meta_tx.blocking_send((path, timestamp, info)).is_err() { |
| 259 | + break; |
| 260 | + } |
| 261 | + } else { |
| 262 | + warn!("Could not read metadata for file: {:?}", path); |
| 263 | + if decode_fail_tx.blocking_send((path, timestamp)).is_err() { |
| 264 | + break; |
| 265 | + } |
| 266 | + } |
| 267 | + } |
| 268 | +} |
| 269 | + |
237 | 270 | async fn run_scanner( |
238 | 271 | pool: SqlitePool, |
239 | 272 | mut scan_settings: ScanSettings, |
@@ -445,85 +478,159 @@ async fn run_scanner( |
445 | 478 | .clamp(2, 8) |
446 | 479 | - 1; |
447 | 480 |
|
448 | | - // we run the discovery and metadata reading stages in separate tasks, that way they can |
449 | | - // run concurrently and no step in the scanning process blocks the other |
450 | | - let (path_tx, path_rx) = tokio::sync::mpsc::channel::<(Utf8PathBuf, SystemTime)>(64); |
| 481 | + let meta_capacity = if scan_settings.slow_disk_mode { |
| 482 | + 64 |
| 483 | + } else { |
| 484 | + num_workers * 8 |
| 485 | + }; |
451 | 486 | let (meta_tx, mut meta_rx) = |
452 | | - tokio::sync::mpsc::channel::<(Utf8PathBuf, SystemTime, FileInformation)>( |
453 | | - num_workers * 8, |
454 | | - ); |
| 487 | + tokio::sync::mpsc::channel::<(Utf8PathBuf, SystemTime, FileInformation)>(meta_capacity); |
455 | 488 | // Channel for files that failed metadata decoding - these should be added to scan_record |
456 | 489 | // immediately since rescanning won't help until the file changes |
457 | 490 | let (decode_fail_tx, mut decode_fail_rx) = |
458 | | - tokio::sync::mpsc::channel::<(Utf8PathBuf, SystemTime)>(num_workers * 8); |
| 491 | + tokio::sync::mpsc::channel::<(Utf8PathBuf, SystemTime)>(meta_capacity); |
459 | 492 |
|
460 | 493 | let cancel_flag = Arc::new(AtomicBool::new(false)); |
461 | 494 |
|
462 | | - // Discovery |
463 | | - let cancel_for_discover = Arc::clone(&cancel_flag); |
464 | | - let discover_handle = match &mode { |
465 | | - ScanMode::Full { .. } => { |
466 | | - let mut settings_for_discover = scan_settings.clone(); |
467 | | - settings_for_discover.paths = full_available_paths; |
468 | | - let scan_record_for_discover = scan_record_shared.clone(); |
469 | | - spawn_blocking(move || { |
470 | | - discover( |
471 | | - settings_for_discover, |
472 | | - scan_record_for_discover, |
473 | | - path_tx, |
474 | | - cancel_for_discover, |
475 | | - ) |
476 | | - }) |
477 | | - } |
478 | | - ScanMode::Targeted { paths } => { |
479 | | - let paths = paths.clone(); |
480 | | - spawn_blocking(move || rescan_discover(paths, path_tx, cancel_for_discover)) |
| 495 | + // we run the discovery and metadata reading stages in separate tasks, that way they can |
| 496 | + // run concurrently and no step in the scanning process blocks the other |
| 497 | + let spawn_discover = |path_tx: tokio::sync::mpsc::Sender<(Utf8PathBuf, SystemTime)>, |
| 498 | + cancel: Arc<AtomicBool>| |
| 499 | + -> tokio::task::JoinHandle<u64> { |
| 500 | + let settings = scan_settings.clone(); |
| 501 | + let paths = full_available_paths.clone(); |
| 502 | + let sr = scan_record_shared.clone(); |
| 503 | + match &mode { |
| 504 | + ScanMode::Full { .. } => { |
| 505 | + let mut settings = settings; |
| 506 | + settings.paths = paths; |
| 507 | + spawn_blocking(move || discover(settings, sr, path_tx, cancel)) |
| 508 | + } |
| 509 | + ScanMode::Targeted { paths } => { |
| 510 | + let paths = paths.clone(); |
| 511 | + spawn_blocking(move || rescan_discover(paths, path_tx, cancel)) |
| 512 | + } |
481 | 513 | } |
482 | 514 | }; |
483 | 515 |
|
484 | | - let path_rx_shared = Arc::new(Mutex::new(path_rx)); |
485 | | - |
486 | | - for _ in 0..num_workers { |
487 | | - let path_rx = Arc::clone(&path_rx_shared); |
488 | | - let meta_tx = meta_tx.clone(); |
489 | | - let decode_fail_tx = decode_fail_tx.clone(); |
490 | | - let cancel_flag = Arc::clone(&cancel_flag); |
491 | | - spawn_blocking(move || { |
492 | | - let mut art_cache: FxHashMap<Utf8PathBuf, Option<Arc<[u8]>>> = FxHashMap::default(); |
493 | | - loop { |
494 | | - if cancel_flag.load(Ordering::Relaxed) { |
495 | | - break; |
496 | | - } |
| 516 | + let mut slow_discover_task: Option<tokio::task::JoinHandle<u64>> = None; |
497 | 517 |
|
498 | | - let item = { |
499 | | - let mut rx = path_rx.blocking_lock(); |
500 | | - rx.blocking_recv() |
501 | | - }; |
502 | | - let Some((path, timestamp)) = item else { |
503 | | - break; // channel closed, discovery complete |
504 | | - }; |
| 518 | + let (discover_handle, path_rx_shared) = if scan_settings.slow_disk_mode { |
| 519 | + let paths_for_disks = full_available_paths.clone(); |
| 520 | + let (disk_groups, mounts_sorted, mount_to_channel) = |
| 521 | + tokio::task::spawn_blocking(move || disk::group_paths_by_disk(&paths_for_disks)) |
| 522 | + .await |
| 523 | + .expect("disk grouping task panicked"); |
| 524 | + let num_disks = disk_groups.len().max(1); |
| 525 | + |
| 526 | + // Create per-disk channels and collect receivers |
| 527 | + let mut disk_txs: Vec<tokio::sync::mpsc::Sender<(Utf8PathBuf, SystemTime)>> = |
| 528 | + Vec::with_capacity(num_disks); |
| 529 | + let mut disk_rxs: Vec<tokio::sync::mpsc::Receiver<(Utf8PathBuf, SystemTime)>> = |
| 530 | + Vec::with_capacity(num_disks); |
| 531 | + for _ in 0..num_disks { |
| 532 | + let (tx, rx) = tokio::sync::mpsc::channel(64); |
| 533 | + disk_txs.push(tx); |
| 534 | + disk_rxs.push(rx); |
| 535 | + } |
505 | 536 |
|
506 | | - if cancel_flag.load(Ordering::Relaxed) { |
| 537 | + // Shared discover path_tx / path_rx |
| 538 | + let (path_tx, mut path_rx) = |
| 539 | + tokio::sync::mpsc::channel::<(Utf8PathBuf, SystemTime)>(64); |
| 540 | + |
| 541 | + let cancel_for_discover = Arc::clone(&cancel_flag); |
| 542 | + let discover_task = spawn_discover(path_tx, cancel_for_discover); |
| 543 | + slow_discover_task = Some(discover_task); |
| 544 | + |
| 545 | + let router_cancel = Arc::clone(&cancel_flag); |
| 546 | + let router_disk_txs = disk_txs.clone(); |
| 547 | + let router = spawn_blocking(move || { |
| 548 | + let mut dir_cache: FxHashMap<Utf8PathBuf, usize> = FxHashMap::default(); |
| 549 | + let mut routed: u64 = 0; |
| 550 | + |
| 551 | + while let Some((path, timestamp)) = path_rx.blocking_recv() { |
| 552 | + if router_cancel.load(Ordering::Relaxed) { |
507 | 553 | break; |
508 | 554 | } |
509 | 555 |
|
510 | | - if let Some(info) = read_metadata_for_path(&path, &mut art_cache) { |
511 | | - if cancel_flag.load(Ordering::Relaxed) { |
512 | | - break; |
513 | | - } |
514 | | - |
515 | | - if meta_tx.blocking_send((path, timestamp, info)).is_err() { |
516 | | - break; |
517 | | - } |
518 | | - } else { |
519 | | - warn!("Could not read metadata for file: {:?}", path); |
520 | | - if decode_fail_tx.blocking_send((path, timestamp)).is_err() { |
521 | | - break; |
522 | | - } |
| 556 | + // Find the mount point for this path (cached by parent dir) |
| 557 | + let parent = path.parent().map(|p| p.to_path_buf()); |
| 558 | + let disk_idx = parent |
| 559 | + .as_ref() |
| 560 | + .and_then(|p| dir_cache.get(p).copied()) |
| 561 | + .or_else(|| { |
| 562 | + // paths from discovery are already canonical |
| 563 | + let mount_point = mounts_sorted |
| 564 | + .iter() |
| 565 | + .find(|m| path.as_std_path().starts_with(m.as_std_path()))?; |
| 566 | + let channel = match mount_to_channel.get(mount_point).copied() { |
| 567 | + Some(ch) => ch, |
| 568 | + None => { |
| 569 | + warn!( |
| 570 | + "no physical device ID for mount point {:?}, routing to fallback channel 0", |
| 571 | + mount_point |
| 572 | + ); |
| 573 | + 0 |
| 574 | + } |
| 575 | + }; |
| 576 | + if let Some(p) = &parent { |
| 577 | + dir_cache.insert(p.clone(), channel); |
| 578 | + } |
| 579 | + Some(channel) |
| 580 | + }) |
| 581 | + .unwrap_or(0); |
| 582 | + |
| 583 | + if router_disk_txs[disk_idx] |
| 584 | + .blocking_send((path, timestamp)) |
| 585 | + .is_err() |
| 586 | + { |
| 587 | + break; |
523 | 588 | } |
| 589 | + routed += 1; |
524 | 590 | } |
| 591 | + |
| 592 | + routed |
525 | 593 | }); |
526 | | - } |
| 594 | + |
| 595 | + for mut rx in disk_rxs { |
| 596 | + let meta_tx = meta_tx.clone(); |
| 597 | + let decode_fail_tx = decode_fail_tx.clone(); |
| 598 | + let cancel_flag = Arc::clone(&cancel_flag); |
| 599 | + spawn_blocking(move || { |
| 600 | + run_metadata_reader(|| rx.blocking_recv(), meta_tx, decode_fail_tx, cancel_flag) |
| 601 | + }); |
| 602 | + } |
| 603 | + |
| 604 | + (router, None) |
| 605 | + } else { |
| 606 | + let (path_tx, path_rx) = tokio::sync::mpsc::channel::<(Utf8PathBuf, SystemTime)>(64); |
| 607 | + |
| 608 | + let cancel_for_discover = Arc::clone(&cancel_flag); |
| 609 | + let discover = spawn_discover(path_tx, cancel_for_discover); |
| 610 | + |
| 611 | + let path_rx_shared = Arc::new(Mutex::new(path_rx)); |
| 612 | + |
| 613 | + for _ in 0..num_workers { |
| 614 | + let path_rx = Arc::clone(&path_rx_shared); |
| 615 | + let meta_tx = meta_tx.clone(); |
| 616 | + let decode_fail_tx = decode_fail_tx.clone(); |
| 617 | + let cancel_flag = Arc::clone(&cancel_flag); |
| 618 | + spawn_blocking(move || { |
| 619 | + run_metadata_reader( |
| 620 | + || { |
| 621 | + let mut rx = path_rx.blocking_lock(); |
| 622 | + rx.blocking_recv() |
| 623 | + }, |
| 624 | + meta_tx, |
| 625 | + decode_fail_tx, |
| 626 | + cancel_flag, |
| 627 | + ) |
| 628 | + }); |
| 629 | + } |
| 630 | + |
| 631 | + (discover, Some(path_rx_shared)) |
| 632 | + }; |
| 633 | + |
527 | 634 | // Drop the original senders so the channels close when all worker clones are dropped. |
528 | 635 | drop(meta_tx); |
529 | 636 | drop(decode_fail_tx); |
@@ -711,11 +818,16 @@ async fn run_scanner( |
711 | 818 | } |
712 | 819 |
|
713 | 820 | cancel_flag.store(true, Ordering::Relaxed); |
714 | | - drop(path_rx_shared); |
| 821 | + if let Some(path_rx_shared) = path_rx_shared { |
| 822 | + drop(path_rx_shared); |
| 823 | + } |
715 | 824 |
|
716 | 825 | if !discovery_complete { |
717 | 826 | let _ = discover_handle.await.expect("discover task panicked"); |
718 | 827 | } |
| 828 | + if let Some(task) = slow_discover_task { |
| 829 | + let _ = task.await.expect("discover task panicked"); |
| 830 | + } |
719 | 831 |
|
720 | 832 | // drain remaining decode failures |
721 | 833 | while let Ok((path, timestamp)) = decode_fail_rx.try_recv() { |
@@ -815,3 +927,74 @@ pub fn start_scanner(pool: SqlitePool, settings: ScanSettings) -> ScanInterface |
815 | 927 |
|
816 | 928 | ScanInterface::new(Some(events_rx), cmd_tx) |
817 | 929 | } |
| 930 | + |
| 931 | +#[cfg(test)] |
| 932 | +mod tests { |
| 933 | + use super::*; |
| 934 | + use crate::test_support::TestDir; |
| 935 | + |
| 936 | + #[test] |
| 937 | + fn run_metadata_reader_exits_on_cancel_flag() { |
| 938 | + let cancel_flag = Arc::new(AtomicBool::new(true)); |
| 939 | + let (meta_tx, _meta_rx) = channel(1); |
| 940 | + let (fail_tx, _fail_rx) = channel(1); |
| 941 | + |
| 942 | + // recv should never be called because cancel_flag is set |
| 943 | + let mut called = false; |
| 944 | + run_metadata_reader( |
| 945 | + || { |
| 946 | + called = true; |
| 947 | + None |
| 948 | + }, |
| 949 | + meta_tx, |
| 950 | + fail_tx, |
| 951 | + cancel_flag, |
| 952 | + ); |
| 953 | + |
| 954 | + assert!(!called, "recv should not be called when cancelled"); |
| 955 | + } |
| 956 | + |
| 957 | + #[test] |
| 958 | + fn run_metadata_reader_exits_on_channel_close() { |
| 959 | + let cancel_flag = Arc::new(AtomicBool::new(false)); |
| 960 | + let (meta_tx, _meta_rx) = channel(1); |
| 961 | + let (fail_tx, _fail_rx) = channel(1); |
| 962 | + |
| 963 | + // recv returns None simulating a closed channel |
| 964 | + run_metadata_reader(|| None, meta_tx, fail_tx, cancel_flag); |
| 965 | + // function should return without panicking |
| 966 | + } |
| 967 | + |
| 968 | + #[test] |
| 969 | + fn run_metadata_reader_forwards_decode_failure() { |
| 970 | + let cancel_flag = Arc::new(AtomicBool::new(false)); |
| 971 | + let (meta_tx, _meta_rx) = channel(1); |
| 972 | + let (fail_tx, mut fail_rx) = channel(1); |
| 973 | + |
| 974 | + let dir = TestDir::new("decode-fail-test"); |
| 975 | + let nonexistent = dir.utf8_join("nonexistent.flac"); |
| 976 | + let ts = SystemTime::now(); |
| 977 | + let path_for_recv = nonexistent.clone(); |
| 978 | + |
| 979 | + let mut call_count = 0; |
| 980 | + run_metadata_reader( |
| 981 | + move || { |
| 982 | + call_count += 1; |
| 983 | + if call_count == 1 { |
| 984 | + Some((path_for_recv.clone(), ts)) |
| 985 | + } else { |
| 986 | + None // close after one item |
| 987 | + } |
| 988 | + }, |
| 989 | + meta_tx, |
| 990 | + fail_tx, |
| 991 | + cancel_flag, |
| 992 | + ); |
| 993 | + |
| 994 | + let received = fail_rx |
| 995 | + .try_recv() |
| 996 | + .expect("should have received decode failure"); |
| 997 | + assert_eq!(received.0, nonexistent); |
| 998 | + assert_eq!(received.1, ts); |
| 999 | + } |
| 1000 | +} |
0 commit comments