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 number: SeasonNumber,
65 poster_file_name: Option<String>,
67 },
68 Episode {
70 show: ShowId,
72 season: SeasonNumber,
74 number: 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 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 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}