use crate::media::{TrackId, TrackInfo}; use id3::{Tag, TagLike}; use std::{ fs::{DirEntry, ReadDir}, path::{Path, PathBuf}, }; use thiserror::Error; #[derive(Debug, Error)] pub enum ScannerError { #[error("Cannot scan file")] CannotScan, #[error("File not found")] FileNotFound, #[error("Tag not found")] TagNotFound, #[error("IO error {0}")] IO(std::io::Error), } impl From for ScannerError { fn from(err: std::io::Error) -> Self { Self::IO(err) } } impl From for ScannerError { fn from(err: id3::Error) -> ScannerError { match err.kind { id3::ErrorKind::Io(err) => ScannerError::IO(err), id3::ErrorKind::StringDecoding(_) => ScannerError::CannotScan, id3::ErrorKind::NoTag => ScannerError::TagNotFound, id3::ErrorKind::Parsing => ScannerError::CannotScan, id3::ErrorKind::InvalidInput => ScannerError::CannotScan, id3::ErrorKind::UnsupportedFeature => ScannerError::CannotScan, } } } pub trait MusicScanner: Sync + Send { fn scan<'a>(&'a self) -> Box> + 'a>; } pub struct FileScanner { roots: Vec, } impl FileScanner { pub fn new(roots: Vec) -> Self { Self { roots } } } pub struct FileIterator { dirs: Vec, file_iter: Option, } impl FileIterator { fn scan_file(&self, path: PathBuf) -> Result { let mimetype = mime_guess::from_path(path.clone()) .first() .ok_or(ScannerError::CannotScan)?; match (mimetype.type_(), mimetype.subtype().as_str()) { (mime::AUDIO, "mpeg") => TrackInfo::scan_id3(path, mimetype), /* (mime::AUDIO, "ogg") => Ok(TrackInfo { id: TrackId::from(path.to_str().unwrap().to_owned()), album: None, artist: None, name: path .file_stem() .and_then(|s| s.to_str()) .map(|s| s.to_owned()), track_number: None, filetype: mimetype, }), (mime::AUDIO, "flac") => Ok(TrackInfo { id: TrackId::from(path.to_str().unwrap().to_owned()), album: None, artist: None, name: path .file_stem() .and_then(|s| s.to_str()) .map(|s| s.to_owned()), track_number: None, filetype: mimetype, }), */ _ => Err(ScannerError::CannotScan), } } } impl TrackInfo { fn scan_id3(path: PathBuf, mimetype: mime::Mime) -> Result { let tags = Tag::read_from_path(path.clone()).map_err(ScannerError::from)?; Ok(TrackInfo { id: TrackId::from(path.to_str().unwrap().to_owned()), album: tags.album().map(|s| s.to_owned()), artist: tags.artist().map(|s| s.to_owned()), name: tags.title().map(|s| s.to_owned()).or_else(|| { path.file_stem() .and_then(|s| s.to_str()) .map(|s| s.to_owned()) }), duration: tags.duration(), track_number: None, filetype: mimetype, }) } } enum EntryInfo { Dir(PathBuf), File(PathBuf), } impl Iterator for FileIterator { type Item = Result; fn next(&mut self) -> Option { fn process_entry(entry: DirEntry) -> Result { entry .metadata() .map_err(ScannerError::from) .map(|metadata| { if metadata.is_dir() { EntryInfo::Dir(entry.path()) } else { EntryInfo::File(entry.path()) } }) } let next_file = match &mut self.file_iter { Some(iter) => iter.next(), None => None, }; match next_file { Some(Ok(entry)) => match process_entry(entry) { Ok(EntryInfo::Dir(path)) => { self.dirs.push(path); self.next() } Ok(EntryInfo::File(path)) => Some(self.scan_file(path)), Err(err) => Some(Err(err)), }, Some(Err(err)) => Some(Err(ScannerError::from(err))), None => match self.dirs.pop() { Some(dir) => match dir.read_dir() { Ok(entry) => { self.file_iter = Some(entry); self.next() } Err(err) => { if err.kind() == std::io::ErrorKind::NotFound { Some(Err(ScannerError::FileNotFound)) } else { Some(Err(ScannerError::from(err))) } } }, None => None, }, } } } impl MusicScanner for FileScanner { fn scan<'a>(&'a self) -> Box> + 'a> { Box::new(FileIterator { dirs: self.roots.clone(), file_iter: None, }) } } #[cfg(test)] pub mod factories { use super::*; use crate::media::TrackId; pub struct MockScanner { data: Vec, } impl MockScanner { pub fn new() -> Self { Self { data: vec![ TrackInfo { id: TrackId::from("/home/savanni/Track 1.mp3".to_owned()), track_number: Some(1), name: Some("Track 1".to_owned()), album: Some("Savanni's Demo".to_owned()), artist: Some("Savanni".to_owned()), duration: Some(15), filetype: "audio/mpeg".parse::().unwrap(), }, TrackInfo { id: TrackId::from("/home/savanni/Track 2.mp3".to_owned()), track_number: Some(2), name: Some("Track 2".to_owned()), album: Some("Savanni's Demo".to_owned()), artist: Some("Savanni".to_owned()), duration: Some(15), filetype: "audio/mpeg".parse::().unwrap(), }, TrackInfo { id: TrackId::from("/home/savanni/Track 3.mp3".to_owned()), track_number: Some(3), name: Some("Track 3".to_owned()), album: Some("Savanni's Demo".to_owned()), artist: Some("Savanni".to_owned()), duration: Some(15), filetype: "audio/mpeg".parse::().unwrap(), }, TrackInfo { id: TrackId::from("/home/savanni/Track 4.mp3".to_owned()), track_number: Some(4), name: Some("Track 4".to_owned()), album: Some("Savanni's Demo".to_owned()), artist: Some("Savanni".to_owned()), duration: Some(15), filetype: "audio/mpeg".parse::().unwrap(), }, TrackInfo { id: TrackId::from("/home/savanni/Track 5.mp3".to_owned()), track_number: Some(5), name: Some("Track 5".to_owned()), album: Some("Savanni's Demo".to_owned()), artist: Some("Savanni".to_owned()), duration: Some(15), filetype: "audio/mpeg".parse::().unwrap(), }, ], } } } impl MusicScanner for MockScanner { fn scan<'a>(&'a self) -> Box> + 'a> { Box::new(self.data.iter().map(|t| Ok(t.clone()))) } } }