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