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