//! The collection scanner will scan a folder and its children. use core::pin::Pin; use std::ffi::OsStr; use std::path::Path; use flix_model::id::CollectionId; use async_stream::stream; use tokio::fs; use tokio_stream::Stream; use tokio_stream::wrappers::ReadDirStream; use crate::Error; use crate::macros::is_image_extension; use crate::scanner::{ CollectionScan, EpisodeScan, MediaRef, MovieScan, SeasonScan, ShowScan, generic, movie, show, }; /// A collection item. pub type Item = crate::Item; /// The scanner for collections. #[derive(Debug)] #[expect(clippy::exhaustive_enums, reason = "unlikely to change")] pub enum Scanner { /// A scanned collection. Collection(CollectionScan), /// A scanned movie. Movie(MovieScan), /// A scanned show. Show(ShowScan), /// A scanned episode. Season(SeasonScan), /// A scanned episode. Episode(EpisodeScan), } impl From for Scanner { #[inline] fn from(value: movie::Scanner) -> Self { match value { movie::Scanner::Movie(m) => Self::Movie(m), } } } impl From for Scanner { #[inline] fn from(value: show::Scanner) -> Self { match value { show::Scanner::Show(s) => Self::Show(s), show::Scanner::Season(s) => Self::Season(s), show::Scanner::Episode(e) => Self::Episode(e), } } } impl From for Scanner { #[inline] fn from(value: generic::Scanner) -> Self { match value { generic::Scanner::Collection(c) => Self::Collection(c), generic::Scanner::Movie(m) => Self::Movie(m), generic::Scanner::Show(s) => Self::Show(s), generic::Scanner::Season(s) => Self::Season(s), generic::Scanner::Episode(e) => Self::Episode(e), } } } impl Scanner { /// Scan a folder for a collection. #[inline] #[must_use] #[expect(clippy::allow_attributes, reason = "for tail_expr_drop_order")] #[allow(tail_expr_drop_order, reason = "bug in stream! macro")] #[expect(clippy::semicolon_if_nothing_returned, reason = "false positive")] pub fn scan_collection( path: &Path, parent_ref: Option>, id_ref: MediaRef, ) -> Pin>> { Box::pin(stream!({ let dirs = match fs::read_dir(path).await { Ok(dirs) => dirs, Err(err) => { yield Item { path: path.to_owned(), event: Err(Error::ReadDir(err)), }; return; } }; let mut poster_file_name = None; let mut subdirs_to_scan = Vec::new(); for await dir in ReadDirStream::new(dirs) { match dir { Ok(dir) => { let path = dir.path(); let filetype = match dir.file_type().await { Ok(filetype) => filetype, Err(err) => { yield Item { path, event: Err(Error::FileType(err)), }; continue; } }; if filetype.is_dir() { subdirs_to_scan.push(path); continue; } match path.extension().and_then(OsStr::to_str) { is_image_extension!() => { if poster_file_name.is_some() { yield Item { path, event: Err(Error::DuplicatePosterFile), }; continue; } poster_file_name = path .file_name() .and_then(|s| s.to_str()) .map(ToOwned::to_owned); } Some(_) | None => { yield Item { path, event: Err(Error::UnexpectedFile), }; } } } Err(err) => { yield Item { path: path.to_owned(), event: Err(Error::ReadDirEntry(err)), } } } } yield Item { path: path.to_owned(), event: Ok(Self::Collection(CollectionScan { parent_ref, id_ref: id_ref.clone(), poster_file_name, })), }; for subdir in subdirs_to_scan { for await event in generic::Scanner::scan_detect_folder(&subdir, Some(id_ref.clone())) { yield event.map(Into::into); } } })) } }