use musicfs_cache::{Database, VirtualTree}; use musicfs_cas::ContentFetcher; use musicfs_core::{ AudioMeta, Error, Event, EventBus, FileId, FileMeta, OriginId, RealPath, Result, VirtualPath, }; use musicfs_metadata::MetadataParser; use parking_lot::RwLock; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::UNIX_EPOCH; use tokio::sync::mpsc; use tracing::{info, warn}; pub struct ScanResult { pub new_files: Vec, pub changed: u32, pub deleted: u32, pub unchanged: u32, pub bytes_synced: u64, } pub struct SyncedFileInfo { pub path: String, pub file_id: FileId, pub virtual_path: String, } #[derive(Debug, Clone)] pub struct ScanProgress { pub phase: String, pub current: u32, pub total: u32, pub current_path: String, pub bytes_synced: u64, } pub struct OriginScanner { db: Arc, event_bus: Arc, tree: Arc>, fetcher: Arc, parser: MetadataParser, } impl OriginScanner { pub fn new( db: Arc, event_bus: Arc, tree: Arc>, fetcher: Arc, ) -> Self { Self { db, event_bus, tree, fetcher, parser: MetadataParser, } } pub async fn scan( &self, origin_id: &OriginId, origin_root: &Path, subdir: Option<&str>, progress_tx: mpsc::Sender, ) -> Result { let scan_root = match subdir { Some(sub) if !sub.is_empty() => origin_root.join(sub), _ => origin_root.to_path_buf(), }; if !scan_root.exists() { return Err(Error::Origin(format!( "scan path does not exist: {}", scan_root.display() ))); } // Phase 1: Scanning let audio_files = self.collect_audio_files(&scan_root, &progress_tx)?; let total_files = audio_files.len() as u32; info!(files = total_files, "scan phase complete"); // Phase 2: Hashing + categorization let mut new_files = Vec::new(); let mut unchanged = 0u32; for (i, abs_path) in audio_files.iter().enumerate() { let _ = progress_tx.try_send(ScanProgress { phase: "hashing".to_string(), current: i as u32 + 1, total: total_files, current_path: abs_path.display().to_string(), bytes_synced: 0, }); let rel_path = abs_path.strip_prefix(origin_root).unwrap_or(abs_path); let existing = self.db.get_file_by_real_path(origin_id, rel_path)?; if existing.is_some() { unchanged += 1; continue; } let size = std::fs::metadata(abs_path).map(|m| m.len()).unwrap_or(0); new_files.push(DiscoveredFile { abs_path: abs_path.clone(), rel_path: rel_path.to_path_buf(), size, }); } info!( new = new_files.len(), unchanged = unchanged, "hash phase complete" ); // Phase 3: Indexing let mut synced = Vec::new(); let mut bytes_synced = 0u64; let ingest_total = new_files.len() as u32; for (i, file) in new_files.iter().enumerate() { let _ = progress_tx.try_send(ScanProgress { phase: "indexing".to_string(), current: i as u32 + 1, total: ingest_total, current_path: file.abs_path.display().to_string(), bytes_synced, }); let audio_meta = match self.parser.parse_file(&file.abs_path) { Ok(meta) => meta, Err(e) => { warn!(path = %file.abs_path.display(), error = %e, "parse failed, using defaults"); AudioMeta::default() } }; let virtual_path = derive_virtual_path(&audio_meta, &file.rel_path); let file_id = self.db.upsert_file( origin_id, &file.rel_path, &virtual_path, &audio_meta, UNIX_EPOCH, file.size, )?; let file_meta = FileMeta { id: file_id, virtual_path: virtual_path.clone(), real_path: RealPath { origin_id: origin_id.clone(), path: file.rel_path.clone(), }, size: file.size, mtime: UNIX_EPOCH, content_hash: None, audio: Some(audio_meta), }; { let mut tree = self.tree.write(); tree.insert_file(&file_meta); } self.fetcher.register_file(file_meta.clone()); self.event_bus.publish(Event::FileAdded { path: virtual_path.clone(), origin_id: origin_id.clone(), }); bytes_synced += file.size; synced.push(SyncedFileInfo { path: file.abs_path.display().to_string(), file_id, virtual_path: virtual_path.as_str().to_string(), }); } Ok(ScanResult { new_files: synced, changed: 0, deleted: 0, unchanged, bytes_synced, }) } fn collect_audio_files( &self, scan_root: &Path, progress_tx: &mpsc::Sender, ) -> Result> { let mut files = Vec::new(); self.walk_dir(scan_root, &mut files, progress_tx)?; Ok(files) } fn walk_dir( &self, dir: &Path, files: &mut Vec, progress_tx: &mpsc::Sender, ) -> Result<()> { let entries = std::fs::read_dir(dir) .map_err(|e| Error::Origin(format!("read_dir {}: {}", dir.display(), e)))?; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { self.walk_dir(&path, files, progress_tx)?; } else if is_audio_file(&path) { files.push(path.clone()); let _ = progress_tx.try_send(ScanProgress { phase: "scanning".to_string(), current: files.len() as u32, total: 0, current_path: path.display().to_string(), bytes_synced: 0, }); } } Ok(()) } } fn derive_virtual_path(meta: &AudioMeta, rel_path: &Path) -> VirtualPath { let artist = meta.artist.as_deref().unwrap_or("Unknown Artist"); let album = meta.album.as_deref().unwrap_or("Unknown Album"); let filename = rel_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown"); VirtualPath::new(format!("/{}/{}/{}", artist, album, filename)) } fn is_audio_file(path: &Path) -> bool { matches!( path.extension() .and_then(|e| e.to_str()) .map(|e| e.to_lowercase()) .as_deref(), Some("flac" | "mp3" | "ogg" | "wav" | "m4a" | "aac" | "opus") ) } struct DiscoveredFile { abs_path: PathBuf, rel_path: PathBuf, size: u64, }