Skip to main content

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(original: &str) -> &str {
76		let mut s = original;
77		while let Some('0'..='9') = s.chars().next() {
78			s = &s[1..]
79		}
80		s.strip_prefix(" - ").unwrap_or(original)
81	}
82
83	/// Detect the type of a folder and call the correct scanner. Use
84	/// this only for detecting possibly ambiguous media:
85	///   - Collections
86	///   - Movies
87	///   - Shows
88	pub fn scan_detect_folder(
89		path: &Path,
90		parent: Option<MediaRef<CollectionId>>,
91	) -> impl Stream<Item = Item> {
92		enum MediaType {
93			Collection,
94			Movie,
95			Show,
96		}
97
98		let media_folder_re = MEDIA_FOLDER_REGEX.get_or_init(|| {
99			Regex::new(r"^[[[:alnum:]]' -]+ \([[:digit:]]+\)( \[[[:digit:]]+\])?$")
100				.unwrap_or_else(|err| panic!("regex is invalid: {err}"))
101		});
102		let season_folder_re = SEASON_FOLDER_REGEX.get_or_init(|| {
103			Regex::new(r"^S[[:digit:]]+$").unwrap_or_else(|err| panic!("regex is invalid: {err}"))
104		});
105
106		stream!({
107			let Some(dir_name) = path.file_name().and_then(OsStr::to_str) else {
108				yield Item {
109					path: path.to_owned(),
110					event: Err(Error::UnexpectedFolder),
111				};
112				return;
113			};
114
115			let dir_name = Self::strip_numeric_prefix(dir_name);
116
117			// Use the explicit ID ("[X]") if it exists, otherwise parse the folder name
118			let media_id = if let Some((id_str, _)) = dir_name
119				.split_once('[')
120				.and_then(|(_, s)| s.split_once(']'))
121			{
122				let Ok(id) = id_str.parse::<RawId>() else {
123					yield Item {
124						path: path.to_owned(),
125						event: Err(Error::UnexpectedFolder),
126					};
127					return;
128				};
129				Either::Left(id)
130			} else {
131				Either::Right(flix_model::text::normalize_fs_name(dir_name))
132			};
133
134			let media_type: MediaType;
135			if media_folder_re.is_match(dir_name) {
136				let dirs = match fs::read_dir(path).await {
137					Ok(dirs) => dirs,
138					Err(err) => {
139						yield Item {
140							path: path.to_owned(),
141							event: Err(Error::ReadDir(err)),
142						};
143						return;
144					}
145				};
146
147				let mut is_show = false;
148
149				for await dir in ReadDirStream::new(dirs) {
150					match dir {
151						Ok(dir) => {
152							let path = dir.path();
153
154							let filetype = match dir.file_type().await {
155								Ok(filetype) => filetype,
156								Err(err) => {
157									yield Item {
158										path,
159										event: Err(Error::FileType(err)),
160									};
161									continue;
162								}
163							};
164							if !filetype.is_dir() {
165								continue;
166							}
167
168							let Some(folder_name) = path.file_name().and_then(OsStr::to_str) else {
169								yield Item {
170									path,
171									event: Err(Error::UnexpectedFolder),
172								};
173								continue;
174							};
175
176							if season_folder_re.is_match(folder_name) {
177								is_show = true;
178								break;
179							}
180						}
181						Err(err) => {
182							yield Item {
183								path: path.to_owned(),
184								event: Err(Error::ReadDirEntry(err)),
185							};
186						}
187					}
188				}
189
190				if is_show {
191					media_type = MediaType::Show;
192				} else {
193					media_type = MediaType::Movie;
194				}
195			} else {
196				media_type = MediaType::Collection;
197			}
198
199			match media_type {
200				MediaType::Collection => {
201					let id = match media_id {
202						Either::Left(raw) => MediaRef::Id(CollectionId::from_raw(raw)),
203						Either::Right(slug) => MediaRef::Slug(slug),
204					};
205
206					for await event in collection::Scanner::scan_collection(path, parent, id) {
207						yield event.map(|e| e.into());
208					}
209				}
210				MediaType::Movie => {
211					let id = match media_id {
212						Either::Left(raw) => MediaRef::Id(MovieId::from_raw(raw)),
213						Either::Right(slug) => MediaRef::Slug(slug),
214					};
215
216					for await event in movie::Scanner::scan_movie(path, parent, id) {
217						yield event.map(|e| e.into());
218					}
219				}
220				MediaType::Show => {
221					let id = match media_id {
222						Either::Left(raw) => MediaRef::Id(ShowId::from_raw(raw)),
223						Either::Right(slug) => MediaRef::Slug(slug),
224					};
225
226					for await event in show::Scanner::scan_show(path, parent, id) {
227						yield event.map(|e| e.into());
228					}
229				}
230			}
231		})
232	}
233}