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};
9use flix_model::numbers::{EpisodeNumbers, SeasonNumber};
10
11use async_stream::stream;
12use regex::Regex;
13use tokio::fs;
14use tokio_stream::Stream;
15use tokio_stream::wrappers::ReadDirStream;
16
17use crate::Error;
18use crate::scanner::{collection, movie, show};
19
20static MEDIA_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
21static SEASON_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
22
23/// A collection item
24pub type Item = crate::Item<Scanner>;
25
26/// The scanner for collections
27pub enum Scanner {
28	/// A scanned collection
29	Collection {
30		/// The ID of the parent collection (if any)
31		parent: Option<CollectionId>,
32		/// The ID of the collection
33		id: CollectionId,
34		/// The file name of the poster file
35		poster_file_name: Option<String>,
36	},
37
38	/// A scanned movie
39	Movie {
40		/// The ID of the parent collection (if any)
41		parent: Option<CollectionId>,
42		/// The ID of the movie
43		id: MovieId,
44		/// The file name of the media file
45		media_file_name: String,
46		/// The file name of the poster file
47		poster_file_name: Option<String>,
48	},
49
50	/// A scanned show
51	Show {
52		/// The ID of the parent collection (if any)
53		parent: Option<CollectionId>,
54		/// The ID of the show
55		id: ShowId,
56		/// The file name of the poster file
57		poster_file_name: Option<String>,
58	},
59	/// A scanned episode
60	Season {
61		/// The ID of the show this season belongs to
62		show: ShowId,
63		/// The season this episode belongs to
64		season: SeasonNumber,
65		/// The file name of the poster file
66		poster_file_name: Option<String>,
67	},
68	/// A scanned episode
69	Episode {
70		/// The ID of the show this episode belongs to
71		show: ShowId,
72		/// The season this episode belongs to
73		season: SeasonNumber,
74		/// The number(s) of this episode
75		episode: EpisodeNumbers,
76		/// The file name of the media file
77		media_file_name: String,
78		/// The file name of the poster file
79		poster_file_name: Option<String>,
80	},
81}
82
83impl From<collection::Scanner> for Scanner {
84	fn from(value: collection::Scanner) -> Self {
85		match value {
86			collection::Scanner::Collection {
87				parent,
88				id,
89				poster_file_name,
90			} => Self::Collection {
91				parent,
92				id,
93				poster_file_name,
94			},
95			collection::Scanner::Movie {
96				parent,
97				id,
98				media_file_name,
99				poster_file_name,
100			} => Self::Movie {
101				parent,
102				id,
103				media_file_name,
104				poster_file_name,
105			},
106			collection::Scanner::Show {
107				parent,
108				id,
109				poster_file_name,
110			} => Self::Show {
111				parent,
112				id,
113				poster_file_name,
114			},
115			collection::Scanner::Season {
116				show,
117				season,
118				poster_file_name,
119			} => Self::Season {
120				show,
121				season,
122				poster_file_name,
123			},
124			collection::Scanner::Episode {
125				show,
126				season,
127				episode,
128				media_file_name,
129				poster_file_name,
130			} => Self::Episode {
131				show,
132				season,
133				episode,
134				media_file_name,
135				poster_file_name,
136			},
137		}
138	}
139}
140
141impl From<movie::Scanner> for Scanner {
142	fn from(value: movie::Scanner) -> Self {
143		match value {
144			movie::Scanner::Movie {
145				parent,
146				id,
147				media_file_name,
148				poster_file_name,
149			} => Self::Movie {
150				parent,
151				id,
152				media_file_name,
153				poster_file_name,
154			},
155		}
156	}
157}
158
159impl From<show::Scanner> for Scanner {
160	fn from(value: show::Scanner) -> Self {
161		match value {
162			show::Scanner::Show {
163				parent,
164				id,
165				poster_file_name,
166			} => Self::Show {
167				parent,
168				id,
169				poster_file_name,
170			},
171			show::Scanner::Season {
172				show,
173				season,
174				poster_file_name,
175			} => Self::Season {
176				show,
177				season,
178				poster_file_name,
179			},
180			show::Scanner::Episode {
181				show,
182				season,
183				episode,
184				media_file_name,
185				poster_file_name,
186			} => Self::Episode {
187				show,
188				season,
189				episode,
190				media_file_name,
191				poster_file_name,
192			},
193		}
194	}
195}
196
197impl Scanner {
198	/// Detect the type of a folder and call the correct scanner. Use
199	/// this only for detecting possibly ambiguous media:
200	///   - Collections
201	///   - Movies
202	///   - Shows
203	pub fn scan_detect_folder(
204		path: &Path,
205		parent: Option<CollectionId>,
206	) -> impl Stream<Item = Item> {
207		enum MediaType {
208			Collection,
209			Movie,
210			Show,
211		}
212
213		let media_folder_re = MEDIA_FOLDER_REGEX.get_or_init(|| {
214			Regex::new(r"^[[[:alnum:]] -]+ \([[:digit:]]+\) \[[[:digit:]]+\]$")
215				.unwrap_or_else(|err| panic!("regex is invalid: {err}"))
216		});
217		let season_folder_re = SEASON_FOLDER_REGEX.get_or_init(|| {
218			Regex::new(r"^S[[:digit:]]+$").unwrap_or_else(|err| panic!("regex is invalid: {err}"))
219		});
220
221		stream!({
222			let Some(dir_name) = path.file_name().and_then(OsStr::to_str) else {
223				yield Item {
224					path: path.to_owned(),
225					event: Err(Error::UnexpectedFolder),
226				};
227				return;
228			};
229
230			let Some(Ok(id)) = dir_name
231				.split_once('[')
232				.and_then(|(_, s)| s.split_once(']'))
233				.map(|(s, _)| s.parse::<RawId>())
234			else {
235				yield Item {
236					path: path.to_owned(),
237					event: Err(Error::UnexpectedFolder),
238				};
239				return;
240			};
241
242			let media_type: MediaType;
243			if media_folder_re.is_match(dir_name) {
244				let dirs = match fs::read_dir(path).await {
245					Ok(dirs) => dirs,
246					Err(err) => {
247						yield Item {
248							path: path.to_owned(),
249							event: Err(Error::ReadDir(err)),
250						};
251						return;
252					}
253				};
254
255				let mut is_show = false;
256
257				for await dir in ReadDirStream::new(dirs) {
258					match dir {
259						Ok(dir) => {
260							let filetype = match dir.file_type().await {
261								Ok(filetype) => filetype,
262								Err(err) => {
263									yield Item {
264										path: path.to_owned(),
265										event: Err(Error::FileType(err)),
266									};
267									continue;
268								}
269							};
270							if !filetype.is_dir() {
271								continue;
272							}
273
274							let dir_path = dir.path();
275							let Some(folder_name) = dir_path.file_name().and_then(OsStr::to_str)
276							else {
277								yield Item {
278									path: path.to_owned(),
279									event: Err(Error::UnexpectedFolder),
280								};
281								continue;
282							};
283
284							if season_folder_re.is_match(folder_name) {
285								is_show = true;
286								break;
287							}
288						}
289						Err(err) => {
290							yield Item {
291								path: path.to_owned(),
292								event: Err(Error::ReadDirEntry(err)),
293							};
294						}
295					}
296				}
297
298				if is_show {
299					media_type = MediaType::Show;
300				} else {
301					media_type = MediaType::Movie;
302				}
303			} else {
304				media_type = MediaType::Collection;
305			}
306
307			match media_type {
308				MediaType::Collection => {
309					for await event in collection::Scanner::scan_collection(
310						path,
311						parent,
312						CollectionId::from_raw(id),
313					) {
314						yield event.map(|e| e.into());
315					}
316				}
317				MediaType::Movie => {
318					for await event in
319						movie::Scanner::scan_movie(path, parent, MovieId::from_raw(id))
320					{
321						yield event.map(|e| e.into());
322					}
323				}
324				MediaType::Show => {
325					for await event in show::Scanner::scan_show(path, parent, ShowId::from_raw(id))
326					{
327						yield event.map(|e| e.into());
328					}
329				}
330			}
331		})
332	}
333}