1use 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
23pub type Item = crate::Item<Scanner>;
25
26pub enum Scanner {
28 Collection {
30 parent: Option<CollectionId>,
32 id: CollectionId,
34 poster_file_name: Option<String>,
36 },
37
38 Movie {
40 parent: Option<CollectionId>,
42 id: MovieId,
44 media_file_name: String,
46 poster_file_name: Option<String>,
48 },
49
50 Show {
52 parent: Option<CollectionId>,
54 id: ShowId,
56 poster_file_name: Option<String>,
58 },
59 Season {
61 show: ShowId,
63 season: SeasonNumber,
65 poster_file_name: Option<String>,
67 },
68 Episode {
70 show: ShowId,
72 season: SeasonNumber,
74 episode: EpisodeNumbers,
76 media_file_name: String,
78 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 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}