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 this episode belongs to
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 episode belongs to
62		show: ShowId,
63		/// The season this episode belongs to
64		number: 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		number: 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				number,
118				poster_file_name,
119			} => Self::Season {
120				show,
121				number,
122				poster_file_name,
123			},
124			collection::Scanner::Episode {
125				show,
126				season,
127				number,
128				media_file_name,
129				poster_file_name,
130			} => Self::Episode {
131				show,
132				season,
133				number,
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				number,
174				poster_file_name,
175			} => Self::Season {
176				show,
177				number,
178				poster_file_name,
179			},
180			show::Scanner::Episode {
181				show,
182				season,
183				number,
184				media_file_name,
185				poster_file_name,
186			} => Self::Episode {
187				show,
188				season,
189				number,
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"^[\w ]+ \(\d+\) \[\d+\]$").unwrap_or_else(|_| panic!("regex is invalid"))
215		});
216		let season_folder_re = SEASON_FOLDER_REGEX
217			.get_or_init(|| Regex::new(r"^S\d+$").unwrap_or_else(|_| panic!("regex is invalid")));
218
219		stream!({
220			let Some(dir_name) = path.file_name().and_then(OsStr::to_str) else {
221				yield Item {
222					path: path.to_owned(),
223					event: Err(Error::UnexpectedFolder),
224				};
225				return;
226			};
227
228			let Some(Ok(id)) = dir_name
229				.split_once('[')
230				.and_then(|(_, s)| s.split_once(']'))
231				.map(|(s, _)| s.parse::<RawId>())
232			else {
233				yield Item {
234					path: path.to_owned(),
235					event: Err(Error::UnexpectedFolder),
236				};
237				return;
238			};
239
240			let media_type: MediaType;
241			if media_folder_re.is_match(dir_name) {
242				let dirs = match fs::read_dir(path).await {
243					Ok(dirs) => dirs,
244					Err(err) => {
245						yield Item {
246							path: path.to_owned(),
247							event: Err(Error::ReadDir(err)),
248						};
249						return;
250					}
251				};
252
253				let mut is_show = false;
254
255				for await dir in ReadDirStream::new(dirs) {
256					match dir {
257						Ok(dir) => {
258							let filetype = match dir.file_type().await {
259								Ok(filetype) => filetype,
260								Err(err) => {
261									yield Item {
262										path: path.to_owned(),
263										event: Err(Error::FileType(err)),
264									};
265									continue;
266								}
267							};
268							if !filetype.is_dir() {
269								continue;
270							}
271
272							let dir_path = dir.path();
273							let Some(folder_name) = dir_path.file_name().and_then(OsStr::to_str)
274							else {
275								yield Item {
276									path: path.to_owned(),
277									event: Err(Error::UnexpectedFolder),
278								};
279								continue;
280							};
281
282							if season_folder_re.is_match(folder_name) {
283								is_show = true;
284								break;
285							}
286						}
287						Err(err) => {
288							yield Item {
289								path: path.to_owned(),
290								event: Err(Error::ReadDirEntry(err)),
291							};
292						}
293					}
294				}
295
296				if is_show {
297					media_type = MediaType::Show;
298				} else {
299					media_type = MediaType::Movie;
300				}
301			} else {
302				media_type = MediaType::Collection;
303			}
304
305			match media_type {
306				MediaType::Collection => {
307					for await event in collection::Scanner::scan_collection(
308						path,
309						parent,
310						CollectionId::from_raw(id),
311					) {
312						yield event.map(|e| e.into());
313					}
314				}
315				MediaType::Movie => {
316					for await event in
317						movie::Scanner::scan_movie(path, parent, MovieId::from_raw(id))
318					{
319						yield event.map(|e| e.into());
320					}
321				}
322				MediaType::Show => {
323					for await event in show::Scanner::scan_show(path, parent, ShowId::from_raw(id))
324					{
325						yield event.map(|e| e.into());
326					}
327				}
328			}
329		})
330	}
331}