Throw away flix files in favor of a flix database

This commit is contained in:
2025-09-18 22:41:34 -06:00
parent ba9c3fa03d
commit 06110b91a1
117 changed files with 8645 additions and 1054 deletions
+39
View File
@@ -0,0 +1,39 @@
use std::io;
/// The error type for filesystem scanning operations.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// fs::read_dir failed
#[error("fs::read_dir: {0}")]
ReadDir(io::Error),
/// fs::read_dir::next_entry failed
#[error("fs::read_dir::next_entry: {0}")]
ReadDirEntry(io::Error),
/// fs::read_dir::file_type failed
#[error("fs::read_dir::file_type: {0}")]
FileType(io::Error),
/// There is an unexpected file in the directory
#[error("unexpected file")]
UnexpectedFile,
/// There is an unexpected folder in the directory
#[error("unexpected folder")]
UnexpectedFolder,
/// There is an unexpected non-file item in the directory
#[error("unexpected non-file")]
UnexpectedNonFile,
/// There are multiple media files in the directory
#[error("duplicate media file")]
DuplicateMediaFile,
/// There are multiple poster files in the directory
#[error("duplicate poster file")]
DuplicatePosterFile,
/// The directory contains incomplete flix media
#[error("incomplete")]
Incomplete,
/// Some data is inconsistent with the folder structure
#[error("inconsistent")]
Inconsistent,
}
+23
View File
@@ -0,0 +1,23 @@
use std::path::PathBuf;
use crate::Error;
/// An item returned by scanner streams
#[derive(Debug)]
pub struct Item<T> {
/// The path of the item
pub path: PathBuf,
/// The event relating to the item
pub event: Result<T, Error>,
}
impl<T> Item<T> {
/// Helper function for mapping the inner event [Result]
#[inline]
pub fn map<U, F: FnOnce(T) -> U>(self, op: F) -> Item<U> {
Item {
path: self.path,
event: self.event.map(op),
}
}
}
+14
View File
@@ -0,0 +1,14 @@
//! flix-fs provides filesystem scanners for flix media
#![cfg_attr(docsrs, feature(doc_cfg))]
mod macros;
mod error;
pub use error::Error;
mod item;
pub use item::Item;
pub mod scanner;
+13
View File
@@ -0,0 +1,13 @@
macro_rules! is_media_extension {
() => {
Some("mp4" | "mkv")
};
}
pub(super) use is_media_extension;
macro_rules! is_image_extension {
() => {
Some("png" | "jpg")
};
}
pub(super) use is_image_extension;
+282
View File
@@ -0,0 +1,282 @@
//! The collection scanner will scan a folder and its children
use core::pin::Pin;
use std::ffi::OsStr;
use std::path::Path;
use flix_model::id::{CollectionId, MovieId, ShowId};
use flix_model::numbers::{EpisodeNumbers, SeasonNumber};
use async_stream::stream;
use tokio::fs;
use tokio_stream::Stream;
use tokio_stream::wrappers::ReadDirStream;
use crate::Error;
use crate::macros::is_image_extension;
use crate::scanner::{generic, movie, show};
/// A collection item
pub type Item = crate::Item<Scanner>;
/// The scanner for collections
pub enum Scanner {
/// A scanned collection
Collection {
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the collection
id: CollectionId,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned movie
Movie {
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the movie
id: MovieId,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned show
Show {
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the show this episode belongs to
id: ShowId,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned episode
Season {
/// The ID of the show this episode belongs to
show: ShowId,
/// The season this episode belongs to
number: SeasonNumber,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned episode
Episode {
/// The ID of the show this episode belongs to
show: ShowId,
/// The season this episode belongs to
season: SeasonNumber,
/// The number(s) of this episode
number: EpisodeNumbers,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
}
impl From<movie::Scanner> for Scanner {
fn from(value: movie::Scanner) -> Self {
match value {
movie::Scanner::Movie {
parent,
id,
media_file_name,
poster_file_name,
} => Self::Movie {
parent,
id,
media_file_name,
poster_file_name,
},
}
}
}
impl From<show::Scanner> for Scanner {
fn from(value: show::Scanner) -> Self {
match value {
show::Scanner::Show {
parent,
id,
poster_file_name,
} => Self::Show {
parent,
id,
poster_file_name,
},
show::Scanner::Season {
show,
number,
poster_file_name,
} => Self::Season {
show,
number,
poster_file_name,
},
show::Scanner::Episode {
show,
season,
number,
media_file_name,
poster_file_name,
} => Self::Episode {
show,
season,
number,
media_file_name,
poster_file_name,
},
}
}
}
impl From<generic::Scanner> for Scanner {
fn from(value: generic::Scanner) -> Self {
match value {
generic::Scanner::Collection {
parent,
id,
poster_file_name,
} => Self::Collection {
parent,
id,
poster_file_name,
},
generic::Scanner::Movie {
parent,
id,
media_file_name,
poster_file_name,
} => Self::Movie {
parent,
id,
media_file_name,
poster_file_name,
},
generic::Scanner::Show {
parent,
id,
poster_file_name,
} => Self::Show {
parent,
id,
poster_file_name,
},
generic::Scanner::Season {
show,
number,
poster_file_name,
} => Self::Season {
show,
number,
poster_file_name,
},
generic::Scanner::Episode {
show,
season,
number,
media_file_name,
poster_file_name,
} => Self::Episode {
show,
season,
number,
media_file_name,
poster_file_name,
},
}
}
}
impl Scanner {
/// Scan a folder for a collection
pub fn scan_collection(
path: &Path,
parent: Option<CollectionId>,
id: CollectionId,
) -> Pin<Box<impl Stream<Item = Item>>> {
Box::pin(stream!({
let dirs = match fs::read_dir(path).await {
Ok(dirs) => dirs,
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::ReadDir(err)),
};
return;
}
};
let mut poster_file_name = None;
let mut subdirs_to_scan = Vec::new();
for await dir in ReadDirStream::new(dirs) {
match dir {
Ok(dir) => {
let filetype = match dir.file_type().await {
Ok(filetype) => filetype,
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::FileType(err)),
};
continue;
}
};
let path = dir.path();
if filetype.is_dir() {
subdirs_to_scan.push(path);
continue;
}
match path.extension().and_then(OsStr::to_str) {
is_image_extension!() => {
if poster_file_name.is_some() {
yield Item {
path: path.to_owned(),
event: Err(Error::DuplicatePosterFile),
};
continue;
}
poster_file_name = path
.file_name()
.and_then(|s| s.to_str())
.map(ToOwned::to_owned);
}
Some(_) | None => {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFile),
};
}
}
}
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::ReadDirEntry(err)),
}
}
}
}
yield Item {
path: path.to_owned(),
event: Ok(Self::Collection {
parent,
id,
poster_file_name,
}),
};
for subdir in subdirs_to_scan {
for await event in generic::Scanner::scan_detect_folder(&subdir, Some(id)) {
yield event.map(|e| e.into());
}
}
}))
}
}
+147
View File
@@ -0,0 +1,147 @@
//! The episode scanner will scan a folder and exit
use std::ffi::OsStr;
use std::path::Path;
use flix_model::id::ShowId;
use flix_model::numbers::{EpisodeNumbers, SeasonNumber};
use async_stream::stream;
use tokio::fs;
use tokio_stream::Stream;
use tokio_stream::wrappers::ReadDirStream;
use crate::Error;
use crate::macros::{is_image_extension, is_media_extension};
/// An episode item
pub type Item = crate::Item<Scanner>;
/// The scanner for epispdes
pub enum Scanner {
/// A scanned episode
Episode {
/// The ID of the show this episode belongs to
show: ShowId,
/// The season this episode belongs to
season: SeasonNumber,
/// The number(s) of this episode
number: EpisodeNumbers,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
}
impl Scanner {
/// Scan a folder for an episode
pub fn scan_episode(
path: &Path,
show: ShowId,
season: SeasonNumber,
number: EpisodeNumbers,
) -> impl Stream<Item = Item> {
stream!({
let dirs = match fs::read_dir(path).await {
Ok(dirs) => dirs,
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::ReadDir(err)),
};
return;
}
};
let mut media_file_name = None;
let mut poster_file_name = None;
for await dir in ReadDirStream::new(dirs) {
match dir {
Ok(dir) => {
let filetype = match dir.file_type().await {
Ok(filetype) => filetype,
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::FileType(err)),
};
continue;
}
};
if !filetype.is_file() {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedNonFile),
};
continue;
}
let path = dir.path();
match path.extension().and_then(OsStr::to_str) {
is_media_extension!() => {
if media_file_name.is_some() {
yield Item {
path: path.to_owned(),
event: Err(Error::DuplicateMediaFile),
};
continue;
}
media_file_name = path
.file_name()
.and_then(|s| s.to_str())
.map(ToOwned::to_owned);
continue;
}
is_image_extension!() => {
if poster_file_name.is_some() {
yield Item {
path: path.to_owned(),
event: Err(Error::DuplicatePosterFile),
};
continue;
}
poster_file_name = path
.file_name()
.and_then(|s| s.to_str())
.map(ToOwned::to_owned);
}
Some(_) | None => {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFile),
};
}
}
}
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::ReadDirEntry(err)),
}
}
}
}
let Some(media_file_name) = media_file_name else {
yield Item {
path: path.to_owned(),
event: Err(Error::Incomplete),
};
return;
};
yield Item {
path: path.to_owned(),
event: Ok(Self::Episode {
show,
season,
number,
media_file_name,
poster_file_name,
}),
};
})
}
}
+331
View File
@@ -0,0 +1,331 @@
//! The generic scanner will scan a directory and automatically
//! detect the type of media, deferring to the correct scanner.
use std::ffi::OsStr;
use std::path::Path;
use std::sync::OnceLock;
use flix_model::id::{CollectionId, MovieId, RawId, ShowId};
use flix_model::numbers::{EpisodeNumbers, SeasonNumber};
use async_stream::stream;
use regex::Regex;
use tokio::fs;
use tokio_stream::Stream;
use tokio_stream::wrappers::ReadDirStream;
use crate::Error;
use crate::scanner::{collection, movie, show};
static MEDIA_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
static SEASON_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
/// A collection item
pub type Item = crate::Item<Scanner>;
/// The scanner for collections
pub enum Scanner {
/// A scanned collection
Collection {
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the collection
id: CollectionId,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned movie
Movie {
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the movie
id: MovieId,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned show
Show {
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the show this episode belongs to
id: ShowId,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned episode
Season {
/// The ID of the show this episode belongs to
show: ShowId,
/// The season this episode belongs to
number: SeasonNumber,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned episode
Episode {
/// The ID of the show this episode belongs to
show: ShowId,
/// The season this episode belongs to
season: SeasonNumber,
/// The number(s) of this episode
number: EpisodeNumbers,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
}
impl From<collection::Scanner> for Scanner {
fn from(value: collection::Scanner) -> Self {
match value {
collection::Scanner::Collection {
parent,
id,
poster_file_name,
} => Self::Collection {
parent,
id,
poster_file_name,
},
collection::Scanner::Movie {
parent,
id,
media_file_name,
poster_file_name,
} => Self::Movie {
parent,
id,
media_file_name,
poster_file_name,
},
collection::Scanner::Show {
parent,
id,
poster_file_name,
} => Self::Show {
parent,
id,
poster_file_name,
},
collection::Scanner::Season {
show,
number,
poster_file_name,
} => Self::Season {
show,
number,
poster_file_name,
},
collection::Scanner::Episode {
show,
season,
number,
media_file_name,
poster_file_name,
} => Self::Episode {
show,
season,
number,
media_file_name,
poster_file_name,
},
}
}
}
impl From<movie::Scanner> for Scanner {
fn from(value: movie::Scanner) -> Self {
match value {
movie::Scanner::Movie {
parent,
id,
media_file_name,
poster_file_name,
} => Self::Movie {
parent,
id,
media_file_name,
poster_file_name,
},
}
}
}
impl From<show::Scanner> for Scanner {
fn from(value: show::Scanner) -> Self {
match value {
show::Scanner::Show {
parent,
id,
poster_file_name,
} => Self::Show {
parent,
id,
poster_file_name,
},
show::Scanner::Season {
show,
number,
poster_file_name,
} => Self::Season {
show,
number,
poster_file_name,
},
show::Scanner::Episode {
show,
season,
number,
media_file_name,
poster_file_name,
} => Self::Episode {
show,
season,
number,
media_file_name,
poster_file_name,
},
}
}
}
impl Scanner {
/// Detect the type of a folder and call the correct scanner. Use
/// this only for detecting possibly ambiguous media:
/// - Collections
/// - Movies
/// - Shows
pub fn scan_detect_folder(
path: &Path,
parent: Option<CollectionId>,
) -> impl Stream<Item = Item> {
enum MediaType {
Collection,
Movie,
Show,
}
let media_folder_re = MEDIA_FOLDER_REGEX.get_or_init(|| {
Regex::new(r"^[\w ]+ \(\d+\) \[\d+\]$").unwrap_or_else(|_| panic!("regex is invalid"))
});
let season_folder_re = SEASON_FOLDER_REGEX
.get_or_init(|| Regex::new(r"^S\d+$").unwrap_or_else(|_| panic!("regex is invalid")));
stream!({
let Some(dir_name) = path.file_name().and_then(OsStr::to_str) else {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFolder),
};
return;
};
let Some(Ok(id)) = dir_name
.split_once('[')
.and_then(|(_, s)| s.split_once(']'))
.map(|(s, _)| s.parse::<RawId>())
else {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFolder),
};
return;
};
let media_type: MediaType;
if media_folder_re.is_match(dir_name) {
let dirs = match fs::read_dir(path).await {
Ok(dirs) => dirs,
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::ReadDir(err)),
};
return;
}
};
let mut is_show = false;
for await dir in ReadDirStream::new(dirs) {
match dir {
Ok(dir) => {
let filetype = match dir.file_type().await {
Ok(filetype) => filetype,
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::FileType(err)),
};
continue;
}
};
if !filetype.is_dir() {
continue;
}
let dir_path = dir.path();
let Some(folder_name) = dir_path.file_name().and_then(OsStr::to_str)
else {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFolder),
};
continue;
};
if season_folder_re.is_match(folder_name) {
is_show = true;
break;
}
}
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::ReadDirEntry(err)),
};
}
}
}
if is_show {
media_type = MediaType::Show;
} else {
media_type = MediaType::Movie;
}
} else {
media_type = MediaType::Collection;
}
match media_type {
MediaType::Collection => {
for await event in collection::Scanner::scan_collection(
path,
parent,
CollectionId::from_raw(id),
) {
yield event.map(|e| e.into());
}
}
MediaType::Movie => {
for await event in
movie::Scanner::scan_movie(path, parent, MovieId::from_raw(id))
{
yield event.map(|e| e.into());
}
}
MediaType::Show => {
for await event in show::Scanner::scan_show(path, parent, ShowId::from_raw(id))
{
yield event.map(|e| e.into());
}
}
}
})
}
}
+83
View File
@@ -0,0 +1,83 @@
//! The library scanner will scan an entire directory using the generic
//! scanner
use std::ffi::OsStr;
use std::path::Path;
use async_stream::stream;
use tokio::fs;
use tokio_stream::Stream;
use tokio_stream::wrappers::ReadDirStream;
use crate::Error;
use crate::scanner::generic;
/// A library item
pub type Item = crate::Item<generic::Scanner>;
/// The scanner for collections
pub enum Scanner {}
impl Scanner {
/// Scan a folder for a library
pub fn scan_library(path: &Path) -> impl Stream<Item = Item> {
stream!({
let dirs = match fs::read_dir(path).await {
Ok(dirs) => dirs,
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::ReadDir(err)),
};
return;
}
};
let mut subdirs_to_scan = Vec::new();
for await dir in ReadDirStream::new(dirs) {
match dir {
Ok(dir) => {
let filetype = match dir.file_type().await {
Ok(filetype) => filetype,
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::FileType(err)),
};
continue;
}
};
let path = dir.path();
if filetype.is_dir() {
subdirs_to_scan.push(path);
continue;
}
match path.extension().and_then(OsStr::to_str) {
Some(_) | None => {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFile),
};
}
}
}
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::ReadDirEntry(err)),
}
}
}
}
for subdir in subdirs_to_scan {
for await event in generic::Scanner::scan_detect_folder(&subdir, None) {
yield event;
}
}
})
}
}
+16
View File
@@ -0,0 +1,16 @@
//! This module contains all of the filesystem scanner modules
//!
//! The most common scanner to use is [generic::Scanner] which will
//! automatically detect and use the appropriate scanner.
pub mod library;
pub mod generic;
pub mod collection;
pub mod movie;
pub mod episode;
pub mod season;
pub mod show;
+142
View File
@@ -0,0 +1,142 @@
//! The movie scanner will scan a folder and exit
use std::ffi::OsStr;
use std::path::Path;
use flix_model::id::{CollectionId, MovieId};
use async_stream::stream;
use tokio::fs;
use tokio_stream::Stream;
use tokio_stream::wrappers::ReadDirStream;
use crate::Error;
use crate::macros::{is_image_extension, is_media_extension};
/// An movie item
pub type Item = crate::Item<Scanner>;
/// The scanner for movies
pub enum Scanner {
/// A scanned movie
Movie {
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the movie
id: MovieId,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
}
impl Scanner {
/// Scan a folder for a movie
pub fn scan_movie(
path: &Path,
parent: Option<CollectionId>,
id: MovieId,
) -> impl Stream<Item = Item> {
stream!({
let dirs = match fs::read_dir(path).await {
Ok(dirs) => dirs,
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::ReadDir(err)),
};
return;
}
};
let mut media_file_name = None;
let mut poster_file_name = None;
for await dir in ReadDirStream::new(dirs) {
match dir {
Ok(dir) => {
let filetype = match dir.file_type().await {
Ok(filetype) => filetype,
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::FileType(err)),
};
continue;
}
};
if !filetype.is_file() {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedNonFile),
};
continue;
}
let path = dir.path();
match path.extension().and_then(OsStr::to_str) {
is_media_extension!() => {
if media_file_name.is_some() {
yield Item {
path: path.to_owned(),
event: Err(Error::DuplicateMediaFile),
};
continue;
}
media_file_name = path
.file_name()
.and_then(|s| s.to_str())
.map(ToOwned::to_owned);
continue;
}
is_image_extension!() => {
if poster_file_name.is_some() {
yield Item {
path: path.to_owned(),
event: Err(Error::DuplicatePosterFile),
};
continue;
}
poster_file_name = path
.file_name()
.and_then(|s| s.to_str())
.map(ToOwned::to_owned);
}
Some(_) | None => {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFile),
};
}
}
}
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::ReadDirEntry(err)),
}
}
}
}
let Some(media_file_name) = media_file_name else {
yield Item {
path: path.to_owned(),
event: Err(Error::Incomplete),
};
return;
};
yield Item {
path: path.to_owned(),
event: Ok(Self::Movie {
parent,
id,
media_file_name,
poster_file_name,
}),
};
})
}
}
+215
View File
@@ -0,0 +1,215 @@
//! The episode scanner will scan a folder and its children
use std::ffi::OsStr;
use std::path::Path;
use flix_model::id::ShowId;
use flix_model::numbers::{EpisodeNumber, EpisodeNumbers, SeasonNumber};
use async_stream::stream;
use tokio::fs;
use tokio_stream::Stream;
use tokio_stream::wrappers::ReadDirStream;
use crate::Error;
use crate::macros::is_image_extension;
use crate::scanner::episode;
/// A season item
pub type Item = crate::Item<Scanner>;
/// The scanner for seasons
pub enum Scanner {
/// A scanned episode
Season {
/// The ID of the show this episode belongs to
show: ShowId,
/// The season this episode belongs to
number: SeasonNumber,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned episode
Episode {
/// The ID of the show this episode belongs to
show: ShowId,
/// The season this episode belongs to
season: SeasonNumber,
/// The number(s) of this episode
number: EpisodeNumbers,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
}
impl From<episode::Scanner> for Scanner {
fn from(value: episode::Scanner) -> Self {
match value {
episode::Scanner::Episode {
show,
season,
number,
media_file_name,
poster_file_name,
} => Self::Episode {
show,
season,
number,
media_file_name,
poster_file_name,
},
}
}
}
impl Scanner {
/// Scan a folder for a season and its episodes
pub fn scan_season(
path: &Path,
show: ShowId,
number: SeasonNumber,
) -> impl Stream<Item = Item> {
stream!({
let dirs = match fs::read_dir(path).await {
Ok(dirs) => dirs,
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::ReadDir(err)),
};
return;
}
};
let mut poster_file_name = None;
let mut episode_dirs_to_scan = Vec::new();
for await dir in ReadDirStream::new(dirs) {
match dir {
Ok(dir) => {
let filetype = match dir.file_type().await {
Ok(filetype) => filetype,
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::FileType(err)),
};
continue;
}
};
let path = dir.path();
if filetype.is_dir() {
episode_dirs_to_scan.push(path);
continue;
}
match path.extension().and_then(OsStr::to_str) {
is_image_extension!() => {
if poster_file_name.is_some() {
yield Item {
path: path.to_owned(),
event: Err(Error::DuplicatePosterFile),
};
continue;
}
poster_file_name = path
.file_name()
.and_then(|s| s.to_str())
.map(ToOwned::to_owned);
}
Some(_) | None => {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFile),
};
}
}
}
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::ReadDirEntry(err)),
}
}
}
}
yield Item {
path: path.to_owned(),
event: Ok(Self::Season {
show,
number,
poster_file_name,
}),
};
for episode_dir in episode_dirs_to_scan {
let Some(episode_dir_name) = episode_dir.file_name().and_then(OsStr::to_str) else {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFolder),
};
continue;
};
let Some((_, s_e_str)) = episode_dir_name.split_once('S') else {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFolder),
};
continue;
};
let Some((s_str, e_str)) = s_e_str.split_once('E') else {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFolder),
};
continue;
};
let Ok(season) = s_str.parse::<SeasonNumber>() else {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFolder),
};
continue;
};
if season != number {
yield Item {
path: path.to_owned(),
event: Err(Error::Inconsistent),
};
continue;
}
let Ok(episode_numbers) = e_str
.split('E')
.map(|s| s.parse::<EpisodeNumber>())
.collect::<Result<Vec<_>, _>>()
else {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFolder),
};
continue;
};
let Ok(episode_numbers) = EpisodeNumbers::try_from(episode_numbers.as_ref()) else {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFolder),
};
continue;
};
for await event in
episode::Scanner::scan_episode(&episode_dir, show, number, episode_numbers)
{
yield event.map(|e| e.into());
}
}
})
}
}
+193
View File
@@ -0,0 +1,193 @@
//! The show scanner will scan a folder and its children
use std::ffi::OsStr;
use std::path::Path;
use flix_model::id::{CollectionId, ShowId};
use flix_model::numbers::{EpisodeNumbers, SeasonNumber};
use async_stream::stream;
use tokio::fs;
use tokio_stream::Stream;
use tokio_stream::wrappers::ReadDirStream;
use crate::Error;
use crate::macros::is_image_extension;
use crate::scanner::season;
/// A show item
pub type Item = crate::Item<Scanner>;
/// The scanner for shows
pub enum Scanner {
/// A scanned show
Show {
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the show this episode belongs to
id: ShowId,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned episode
Season {
/// The ID of the show this episode belongs to
show: ShowId,
/// The season this episode belongs to
number: SeasonNumber,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned episode
Episode {
/// The ID of the show this episode belongs to
show: ShowId,
/// The season this episode belongs to
season: SeasonNumber,
/// The number(s) of this episode
number: EpisodeNumbers,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
}
impl From<season::Scanner> for Scanner {
fn from(value: season::Scanner) -> Self {
match value {
season::Scanner::Season {
show,
number,
poster_file_name,
} => Self::Season {
show,
number,
poster_file_name,
},
season::Scanner::Episode {
show,
season,
number,
media_file_name,
poster_file_name,
} => Self::Episode {
show,
season,
number,
media_file_name,
poster_file_name,
},
}
}
}
impl Scanner {
/// Scan a folder for a show and its seasons/episodes
pub fn scan_show(
path: &Path,
parent: Option<CollectionId>,
id: ShowId,
) -> impl Stream<Item = Item> {
stream!({
let dirs = match fs::read_dir(path).await {
Ok(dirs) => dirs,
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::ReadDir(err)),
};
return;
}
};
let mut poster_file_name = None;
let mut season_dirs_to_scan = Vec::new();
for await dir in ReadDirStream::new(dirs) {
match dir {
Ok(dir) => {
let filetype = match dir.file_type().await {
Ok(filetype) => filetype,
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::FileType(err)),
};
continue;
}
};
let path = dir.path();
if filetype.is_dir() {
season_dirs_to_scan.push(path);
continue;
}
match path.extension().and_then(OsStr::to_str) {
is_image_extension!() => {
if poster_file_name.is_some() {
yield Item {
path: path.to_owned(),
event: Err(Error::DuplicatePosterFile),
};
continue;
}
poster_file_name = path
.file_name()
.and_then(|s| s.to_str())
.map(ToOwned::to_owned);
}
Some(_) | None => {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFile),
};
}
}
}
Err(err) => {
yield Item {
path: path.to_owned(),
event: Err(Error::ReadDirEntry(err)),
}
}
}
}
yield Item {
path: path.to_owned(),
event: Ok(Self::Show {
parent,
id,
poster_file_name,
}),
};
for season_dir in season_dirs_to_scan {
let Some(season_dir_name) = season_dir.file_name().and_then(OsStr::to_str) else {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFolder),
};
continue;
};
let Some(Ok(season_number)) = season_dir_name
.split_once('S')
.map(|(_, s)| s.parse::<SeasonNumber>())
else {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFolder),
};
continue;
};
for await event in season::Scanner::scan_season(&season_dir, id, season_number) {
yield event.map(|e| e.into());
}
}
})
}
}