flix_fs/scanner/
generic.rs

1//! The generic scanner will scan a directory and automatically
2//! detect the type of media, deferring to the correct scanner.
3
4use std::ffi::OsStr;
5use std::path::Path;
6use std::sync::OnceLock;
7
8use flix_model::id::{CollectionId, MovieId, RawId, ShowId};
9
10use async_stream::stream;
11use either::Either;
12use regex::Regex;
13use tokio::fs;
14use tokio_stream::Stream;
15use tokio_stream::wrappers::ReadDirStream;
16
17use crate::Error;
18use crate::scanner::{
19	CollectionScan, EpisodeScan, MediaRef, MovieScan, SeasonScan, ShowScan, collection, movie, show,
20};
21
22static MEDIA_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
23static SEASON_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
24
25/// A collection item
26pub type Item = crate::Item<Scanner>;
27
28/// The scanner for collections
29#[derive(Debug)]
30pub enum Scanner {
31	/// A scanned collection
32	Collection(CollectionScan),
33	/// A scanned movie
34	Movie(MovieScan),
35	/// A scanned show
36	Show(ShowScan),
37	/// A scanned episode
38	Season(SeasonScan),
39	/// A scanned episode
40	Episode(EpisodeScan),
41}
42
43impl From<collection::Scanner> for Scanner {
44	fn from(value: collection::Scanner) -> Self {
45		match value {
46			collection::Scanner::Collection(c) => Self::Collection(c),
47			collection::Scanner::Movie(m) => Self::Movie(m),
48			collection::Scanner::Show(s) => Self::Show(s),
49			collection::Scanner::Season(s) => Self::Season(s),
50			collection::Scanner::Episode(e) => Self::Episode(e),
51		}
52	}
53}
54
55impl From<movie::Scanner> for Scanner {
56	fn from(value: movie::Scanner) -> Self {
57		match value {
58			movie::Scanner::Movie(m) => Self::Movie(m),
59		}
60	}
61}
62
63impl From<show::Scanner> for Scanner {
64	fn from(value: show::Scanner) -> Self {
65		match value {
66			show::Scanner::Show(s) => Self::Show(s),
67			show::Scanner::Season(s) => Self::Season(s),
68			show::Scanner::Episode(e) => Self::Episode(e),
69		}
70	}
71}
72
73impl Scanner {
74	/// Helper function for stripping allowed numerical prefixes for sorting ("01 - ")
75	fn strip_numeric_prefix(mut s: &str) -> &str {
76		while let Some('0'..='9') = s.chars().next() {
77			s = &s[1..]
78		}
79		s.strip_prefix(" - ").unwrap_or(s)
80	}
81
82	/// Detect the type of a folder and call the correct scanner. Use
83	/// this only for detecting possibly ambiguous media:
84	///   - Collections
85	///   - Movies
86	///   - Shows
87	pub fn scan_detect_folder(
88		path: &Path,
89		parent: Option<MediaRef<CollectionId>>,
90	) -> impl Stream<Item = Item> {
91		enum MediaType {
92			Collection,
93			Movie,
94			Show,
95		}
96
97		let media_folder_re = MEDIA_FOLDER_REGEX.get_or_init(|| {
98			Regex::new(r"^[[[:alnum:]]' -]+ \([[:digit:]]+\)( \[[[:digit:]]+\])?$")
99				.unwrap_or_else(|err| panic!("regex is invalid: {err}"))
100		});
101		let season_folder_re = SEASON_FOLDER_REGEX.get_or_init(|| {
102			Regex::new(r"^S[[:digit:]]+$").unwrap_or_else(|err| panic!("regex is invalid: {err}"))
103		});
104
105		stream!({
106			let Some(dir_name) = path.file_name().and_then(OsStr::to_str) else {
107				yield Item {
108					path: path.to_owned(),
109					event: Err(Error::UnexpectedFolder),
110				};
111				return;
112			};
113
114			let dir_name = Self::strip_numeric_prefix(dir_name);
115
116			// Use the explicit ID ("[X]") if it exists, otherwise parse the folder name
117			let media_id = if let Some((id_str, _)) = dir_name
118				.split_once('[')
119				.and_then(|(_, s)| s.split_once(']'))
120			{
121				let Ok(id) = id_str.parse::<RawId>() else {
122					yield Item {
123						path: path.to_owned(),
124						event: Err(Error::UnexpectedFolder),
125					};
126					return;
127				};
128				Either::Left(id)
129			} else {
130				Either::Right(flix_model::text::normalize_fs_name(dir_name))
131			};
132
133			let media_type: MediaType;
134			if media_folder_re.is_match(dir_name) {
135				let dirs = match fs::read_dir(path).await {
136					Ok(dirs) => dirs,
137					Err(err) => {
138						yield Item {
139							path: path.to_owned(),
140							event: Err(Error::ReadDir(err)),
141						};
142						return;
143					}
144				};
145
146				let mut is_show = false;
147
148				for await dir in ReadDirStream::new(dirs) {
149					match dir {
150						Ok(dir) => {
151							let path = dir.path();
152
153							let filetype = match dir.file_type().await {
154								Ok(filetype) => filetype,
155								Err(err) => {
156									yield Item {
157										path,
158										event: Err(Error::FileType(err)),
159									};
160									continue;
161								}
162							};
163							if !filetype.is_dir() {
164								continue;
165							}
166
167							let Some(folder_name) = path.file_name().and_then(OsStr::to_str) else {
168								yield Item {
169									path,
170									event: Err(Error::UnexpectedFolder),
171								};
172								continue;
173							};
174
175							if season_folder_re.is_match(folder_name) {
176								is_show = true;
177								break;
178							}
179						}
180						Err(err) => {
181							yield Item {
182								path: path.to_owned(),
183								event: Err(Error::ReadDirEntry(err)),
184							};
185						}
186					}
187				}
188
189				if is_show {
190					media_type = MediaType::Show;
191				} else {
192					media_type = MediaType::Movie;
193				}
194			} else {
195				media_type = MediaType::Collection;
196			}
197
198			match media_type {
199				MediaType::Collection => {
200					let id = match media_id {
201						Either::Left(raw) => MediaRef::Id(CollectionId::from_raw(raw)),
202						Either::Right(slug) => MediaRef::Slug(slug),
203					};
204
205					for await event in collection::Scanner::scan_collection(path, parent, id) {
206						yield event.map(|e| e.into());
207					}
208				}
209				MediaType::Movie => {
210					let id = match media_id {
211						Either::Left(raw) => MediaRef::Id(MovieId::from_raw(raw)),
212						Either::Right(slug) => MediaRef::Slug(slug),
213					};
214
215					for await event in movie::Scanner::scan_movie(path, parent, id) {
216						yield event.map(|e| e.into());
217					}
218				}
219				MediaType::Show => {
220					let id = match media_id {
221						Either::Left(raw) => MediaRef::Id(ShowId::from_raw(raw)),
222						Either::Right(slug) => MediaRef::Slug(slug),
223					};
224
225					for await event in show::Scanner::scan_show(path, parent, id) {
226						yield event.map(|e| e.into());
227					}
228				}
229			}
230		})
231	}
232}