Update dependencies and lints

This commit is contained in:
2026-09-07 22:31:04 -07:00
parent b518d762f1
commit 9c288d051c
79 changed files with 3473 additions and 2923 deletions
+10 -5
View File
@@ -1,14 +1,16 @@
[package]
name = "flix-fs"
version = "0.0.20"
license-file.workspace = true
version = "0.1.0"
description = "Filesystem scanner for flix media"
repository = "https://github.com/QuantumShade/flix"
categories = []
repository = "https://git.skrundz.dev/quantumshade/flix"
keywords = ["flix"]
categories = ["multimedia"]
include = ["/src"]
publish = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs]
all-features = true
@@ -23,5 +25,8 @@ thiserror = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true, features = ["fs"] }
[dev-dependencies]
tempfile = { workspace = true }
[lints]
workspace = true
+14 -10
View File
@@ -1,39 +1,43 @@
//! Filesystem errors.
use std::io;
/// The error type for filesystem scanning operations.
#[derive(Debug, thiserror::Error)]
#[expect(clippy::error_impl_error, reason = "Error is a good name here")]
#[expect(clippy::exhaustive_enums, reason = "unlikely to add more variants")]
pub enum Error {
/// fs::read_dir failed
/// `fs::read_dir` failed.
#[error("fs::read_dir: {0}")]
ReadDir(io::Error),
/// fs::read_dir::next_entry failed
/// `fs::read_dir::next_entry` failed.
#[error("fs::read_dir::next_entry: {0}")]
ReadDirEntry(io::Error),
/// fs::read_dir::file_type failed
/// `fs::read_dir::file_type` failed.
#[error("fs::read_dir::file_type: {0}")]
FileType(io::Error),
/// There is an unexpected file in the directory
/// There is an unexpected file in the directory.
#[error("unexpected file")]
UnexpectedFile,
/// There is an unexpected folder in the directory
/// There is an unexpected folder in the directory.
#[error("unexpected folder")]
UnexpectedFolder,
/// There is an unexpected non-file item in the directory
/// There is an unexpected non-file item in the directory.
#[error("unexpected non-file")]
UnexpectedNonFile,
/// There are multiple media files in the directory
/// There are multiple media files in the directory.
#[error("duplicate media file")]
DuplicateMediaFile,
/// There are multiple poster files in the directory
/// There are multiple poster files in the directory.
#[error("duplicate poster file")]
DuplicatePosterFile,
/// The directory contains incomplete flix media
/// The directory contains incomplete flix media.
#[error("incomplete")]
Incomplete,
/// Some data is inconsistent with the folder structure
/// Some data is inconsistent with the folder structure.
#[error("inconsistent")]
Inconsistent,
}
+7 -4
View File
@@ -1,18 +1,21 @@
//! Filesystem scan result items.
use std::path::PathBuf;
use crate::Error;
/// An item returned by scanner streams
/// An item returned by scanner streams.
#[derive(Debug)]
#[expect(clippy::exhaustive_structs, reason = "unlikely to change")]
pub struct Item<T> {
/// The path of the item
/// The path of the item.
pub path: PathBuf,
/// The event relating to the item
/// The event relating to the item.
pub event: Result<T, Error>,
}
impl<T> Item<T> {
/// Helper function for mapping the inner event [Result]
/// Helper function for mapping the inner event [Result].
#[inline]
pub fn map<U, F: FnOnce(T) -> U>(self, op: F) -> Item<U> {
Item {
+1 -1
View File
@@ -1,4 +1,4 @@
//! flix-fs provides filesystem scanners for flix media
//! flix-fs provides filesystem scanners for flix media.
#![cfg_attr(docsrs, feature(doc_cfg))]
+4
View File
@@ -1,3 +1,6 @@
//! Helper macros.
/// Defines media file extensions.
macro_rules! is_media_extension {
() => {
Some("mp4" | "mkv")
@@ -5,6 +8,7 @@ macro_rules! is_media_extension {
}
pub(super) use is_media_extension;
/// Defines image file expensions.
macro_rules! is_image_extension {
() => {
Some("png" | "jpg")
+20 -10
View File
@@ -1,4 +1,4 @@
//! The collection scanner will scan a folder and its children
//! The collection scanner will scan a folder and its children.
use core::pin::Pin;
use std::ffi::OsStr;
@@ -17,24 +17,27 @@ use crate::scanner::{
CollectionScan, EpisodeScan, MediaRef, MovieScan, SeasonScan, ShowScan, generic, movie, show,
};
/// A collection item
/// A collection item.
pub type Item = crate::Item<Scanner>;
/// The scanner for collections
/// The scanner for collections.
#[derive(Debug)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner {
/// A scanned collection
/// A scanned collection.
Collection(CollectionScan),
/// A scanned movie
/// A scanned movie.
Movie(MovieScan),
/// A scanned show
/// A scanned show.
Show(ShowScan),
/// A scanned episode
/// A scanned episode.
Season(SeasonScan),
/// A scanned episode
/// A scanned episode.
Episode(EpisodeScan),
}
impl From<movie::Scanner> for Scanner {
#[inline]
fn from(value: movie::Scanner) -> Self {
match value {
movie::Scanner::Movie(m) => Self::Movie(m),
@@ -43,6 +46,7 @@ impl From<movie::Scanner> for Scanner {
}
impl From<show::Scanner> for Scanner {
#[inline]
fn from(value: show::Scanner) -> Self {
match value {
show::Scanner::Show(s) => Self::Show(s),
@@ -53,6 +57,7 @@ impl From<show::Scanner> for Scanner {
}
impl From<generic::Scanner> for Scanner {
#[inline]
fn from(value: generic::Scanner) -> Self {
match value {
generic::Scanner::Collection(c) => Self::Collection(c),
@@ -65,7 +70,12 @@ impl From<generic::Scanner> for Scanner {
}
impl Scanner {
/// Scan a folder for a collection
/// Scan a folder for a collection.
#[inline]
#[must_use]
#[expect(clippy::allow_attributes, reason = "for tail_expr_drop_order")]
#[allow(tail_expr_drop_order, reason = "bug in stream! macro")]
#[expect(clippy::semicolon_if_nothing_returned, reason = "false positive")]
pub fn scan_collection(
path: &Path,
parent_ref: Option<MediaRef<CollectionId>>,
@@ -151,7 +161,7 @@ impl Scanner {
for await event in
generic::Scanner::scan_detect_folder(&subdir, Some(id_ref.clone()))
{
yield event.map(|e| e.into());
yield event.map(Into::into);
}
}
}))
+12 -5
View File
@@ -1,4 +1,4 @@
//! The episode scanner will scan a folder and exit
//! The episode scanner will scan a folder and exit.
use std::ffi::OsStr;
use std::path::Path;
@@ -15,17 +15,23 @@ use crate::Error;
use crate::macros::{is_image_extension, is_media_extension};
use crate::scanner::{EpisodeScan, MediaRef};
/// An episode item
/// An episode item.
pub type Item = crate::Item<Scanner>;
/// The scanner for epispdes
/// The scanner for epispdes.
#[derive(Debug)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner {
/// A scanned episode
/// A scanned episode.
Episode(EpisodeScan),
}
impl Scanner {
/// Scan a folder for an episode
/// Scan a folder for an episode.
#[inline]
#[expect(clippy::allow_attributes, reason = "for tail_expr_drop_order")]
#[allow(tail_expr_drop_order, reason = "bug in stream! macro")]
#[expect(clippy::semicolon_if_nothing_returned, reason = "false positive")]
pub fn scan_episode(
path: &Path,
show_ref: MediaRef<ShowId>,
@@ -62,6 +68,7 @@ impl Scanner {
continue;
}
};
#[expect(clippy::filetype_is_file, reason = "we only want regular files")]
if !filetype.is_file() {
yield Item {
path,
+33 -18
View File
@@ -19,28 +19,32 @@ use crate::scanner::{
CollectionScan, EpisodeScan, MediaRef, MovieScan, SeasonScan, ShowScan, collection, movie, show,
};
/// Regex for detecting a media folder.
static MEDIA_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
/// Regex for detecting a season folder.
static SEASON_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
/// A collection item
/// A collection item.
pub type Item = crate::Item<Scanner>;
/// The scanner for collections
/// The scanner for collections.
#[derive(Debug)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner {
/// A scanned collection
/// A scanned collection.
Collection(CollectionScan),
/// A scanned movie
/// A scanned movie.
Movie(MovieScan),
/// A scanned show
/// A scanned show.
Show(ShowScan),
/// A scanned episode
/// A scanned episode.
Season(SeasonScan),
/// A scanned episode
/// A scanned episode.
Episode(EpisodeScan),
}
impl From<collection::Scanner> for Scanner {
#[inline]
fn from(value: collection::Scanner) -> Self {
match value {
collection::Scanner::Collection(c) => Self::Collection(c),
@@ -53,6 +57,7 @@ impl From<collection::Scanner> for Scanner {
}
impl From<movie::Scanner> for Scanner {
#[inline]
fn from(value: movie::Scanner) -> Self {
match value {
movie::Scanner::Movie(m) => Self::Movie(m),
@@ -61,6 +66,7 @@ impl From<movie::Scanner> for Scanner {
}
impl From<show::Scanner> for Scanner {
#[inline]
fn from(value: show::Scanner) -> Self {
match value {
show::Scanner::Show(s) => Self::Show(s),
@@ -71,13 +77,13 @@ impl From<show::Scanner> for Scanner {
}
impl Scanner {
/// Helper function for stripping allowed numerical prefixes for sorting ("01 - ")
fn strip_numeric_prefix(original: &str) -> &str {
let mut s = original;
while let Some('0'..='9') = s.chars().next() {
s = &s[1..]
}
s.strip_prefix(" - ").unwrap_or(original)
/// Helper function for stripping allowed numerical prefixes for sorting ("01 - ").
#[inline]
fn strip_numeric_prefix(string: &str) -> &str {
string
.trim_start_matches(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'])
.strip_prefix(" - ")
.unwrap_or(string)
}
/// Detect the type of a folder and call the correct scanner. Use
@@ -85,6 +91,13 @@ impl Scanner {
/// - Collections
/// - Movies
/// - Shows
///
/// # Panics
/// If a static regex is invalid.
#[inline]
#[expect(clippy::allow_attributes, reason = "for tail_expr_drop_order")]
#[allow(tail_expr_drop_order, reason = "bug in stream! macro")]
#[expect(clippy::semicolon_if_nothing_returned, reason = "false positive")]
pub fn scan_detect_folder(
path: &Path,
parent: Option<MediaRef<CollectionId>>,
@@ -95,12 +108,14 @@ impl Scanner {
Show,
}
#[expect(clippy::panic, reason = "static regex string")]
let media_folder_re = MEDIA_FOLDER_REGEX.get_or_init(|| {
Regex::new(r"^[[[:alnum:]]' -]+ \([[:digit:]]+\)( \[[[:digit:]]+\])?$")
.unwrap_or_else(|err| panic!("regex is invalid: {err}"))
});
#[expect(clippy::panic, reason = "static regex string")]
let season_folder_re = SEASON_FOLDER_REGEX.get_or_init(|| {
Regex::new(r"^S[[:digit:]]+$").unwrap_or_else(|err| panic!("regex is invalid: {err}"))
Regex::new("^S[[:digit:]]+$").unwrap_or_else(|err| panic!("regex is invalid: {err}"))
});
stream!({
@@ -204,7 +219,7 @@ impl Scanner {
};
for await event in collection::Scanner::scan_collection(path, parent, id) {
yield event.map(|e| e.into());
yield event.map(Into::into);
}
}
MediaType::Movie => {
@@ -214,7 +229,7 @@ impl Scanner {
};
for await event in movie::Scanner::scan_movie(path, parent, id) {
yield event.map(|e| e.into());
yield event.map(Into::into);
}
}
MediaType::Show => {
@@ -224,7 +239,7 @@ impl Scanner {
};
for await event in show::Scanner::scan_show(path, parent, id) {
yield event.map(|e| e.into());
yield event.map(Into::into);
}
}
}
+11 -5
View File
@@ -1,5 +1,5 @@
//! The library scanner will scan an entire directory using the generic
//! scanner
//! scanner.
use std::ffi::OsStr;
use std::path::Path;
@@ -12,14 +12,20 @@ use tokio_stream::wrappers::ReadDirStream;
use crate::Error;
use crate::scanner::generic;
/// A library item
/// A library item.
pub type Item = crate::Item<generic::Scanner>;
/// The scanner for collections
/// The scanner for collections.
#[derive(Debug, Clone, Copy)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner {}
impl Scanner {
/// Scan a folder for a library
/// Scan a folder for a library.
#[inline]
#[expect(clippy::allow_attributes, reason = "for tail_expr_drop_order")]
#[allow(tail_expr_drop_order, reason = "bug in stream! macro")]
#[expect(clippy::semicolon_if_nothing_returned, reason = "false positive")]
pub fn scan_library(path: &Path) -> impl Stream<Item = Item> {
stream!({
let dirs = match fs::read_dir(path).await {
@@ -58,7 +64,7 @@ impl Scanner {
match path.extension().and_then(OsStr::to_str) {
Some(_) | None => {
yield Item {
path: path.to_owned(),
path: path.clone(),
event: Err(Error::UnexpectedFile),
};
}
+67 -31
View File
@@ -1,6 +1,6 @@
//! This module contains all of the filesystem scanner modules
//! This module contains all of the filesystem scanner modules.
//!
//! The most common scanner to use is [generic::Scanner] which will
//! The most common scanner to use is [`generic::Scanner`] which will
//! automatically detect and use the appropriate scanner.
use flix_model::id::{CollectionId, MovieId, ShowId};
@@ -18,82 +18,118 @@ pub mod episode;
pub mod season;
pub mod show;
/// A reference to a piece of media
/// A reference to a piece of media.
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
#[derive(Debug, Clone)]
pub enum MediaRef<ID> {
/// An explicit ID
/// An explicit ID.
Id(ID),
/// A filesystem slug
/// A filesystem slug.
Slug(String),
}
impl<ID> MediaRef<ID> {
/// Get the slug if it exists
/// Get the slug if it exists.
#[inline]
pub fn into_slug(self) -> Option<String> {
match self {
MediaRef::Id(_) => None,
MediaRef::Slug(slug) => Some(slug),
Self::Id(_) => None,
Self::Slug(slug) => Some(slug),
}
}
}
/// A scanned collection
/// A scanned collection.
#[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "it will be a breaking change when more information is scanned"
)]
pub struct CollectionScan {
/// The ID of the parent collection (if any)
/// The ID of the parent collection (if any).
pub parent_ref: Option<MediaRef<CollectionId>>,
/// The ID of the collection
/// The ID of the collection.
pub id_ref: MediaRef<CollectionId>,
/// The file name of the poster file
/// The file name of the poster file.
pub poster_file_name: Option<String>,
}
/// A scanned movie
/// A scanned movie.
#[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "it will be a breaking change when more information is scanned"
)]
pub struct MovieScan {
/// The ID of the parent collection (if any)
/// The ID of the parent collection (if any).
pub parent_ref: Option<MediaRef<CollectionId>>,
/// The ID of the movie
/// The ID of the movie.
pub id_ref: MediaRef<MovieId>,
/// The file name of the media file
/// The file name of the media file.
pub media_file_name: String,
/// The file name of the poster file
/// The file name of the poster file.
pub poster_file_name: Option<String>,
}
/// A scanned show
/// A scanned show.
#[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "it will be a breaking change when more information is scanned"
)]
pub struct ShowScan {
/// The ID of the parent collection (if any)
/// The ID of the parent collection (if any).
pub parent_ref: Option<MediaRef<CollectionId>>,
/// The ID of the show
/// The ID of the show.
pub id_ref: MediaRef<ShowId>,
/// The file name of the poster file
/// The file name of the poster file.
pub poster_file_name: Option<String>,
}
/// A scanned season
/// A scanned season.
#[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "it will be a breaking change when more information is scanned"
)]
pub struct SeasonScan {
/// The ID of the show this season belongs to
/// The ID of the show this season belongs to.
pub show_ref: MediaRef<ShowId>,
/// The season this episode belongs to
/// The season this episode belongs to.
pub season: SeasonNumber,
/// The file name of the poster file
/// The file name of the poster file.
pub poster_file_name: Option<String>,
}
/// A scanned episode
/// A scanned episode.
#[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "it will be a breaking change when more information is scanned"
)]
pub struct EpisodeScan {
/// The ID of the show this episode belongs to
/// The ID of the show this episode belongs to.
pub show_ref: MediaRef<ShowId>,
/// The season this episode belongs to
/// The season this episode belongs to.
pub season: SeasonNumber,
/// The number(s) of this episode
/// The number(s) of this episode.
pub episode: EpisodeNumbers,
/// The file name of the media file
/// The file name of the media file.
pub media_file_name: String,
/// The file name of the poster file
/// The file name of the poster file.
pub poster_file_name: Option<String>,
}
#[cfg(test)]
mod tests {
use super::{MediaRef, MovieId, library};
#[cfg(not(miri))]
#[test]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
fn use_fn() {
let tmp = tempfile::tempdir().expect("temporary directory exists");
drop(library::Scanner::scan_library(tmp.path()));
drop(MediaRef::Id(MovieId::from_raw(0)).into_slug());
}
}
+12 -5
View File
@@ -1,4 +1,4 @@
//! The movie scanner will scan a folder and exit
//! The movie scanner will scan a folder and exit.
use std::ffi::OsStr;
use std::path::Path;
@@ -14,17 +14,23 @@ use crate::Error;
use crate::macros::{is_image_extension, is_media_extension};
use crate::scanner::{MediaRef, MovieScan};
/// An movie item
/// An movie item.
pub type Item = crate::Item<Scanner>;
/// The scanner for movies
/// The scanner for movies.
#[derive(Debug)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner {
/// A scanned movie
/// A scanned movie.
Movie(MovieScan),
}
impl Scanner {
/// Scan a folder for a movie
/// Scan a folder for a movie.
#[inline]
#[expect(clippy::allow_attributes, reason = "for tail_expr_drop_order")]
#[allow(tail_expr_drop_order, reason = "bug in stream! macro")]
#[expect(clippy::semicolon_if_nothing_returned, reason = "false positive")]
pub fn scan_movie(
path: &Path,
parent_ref: Option<MediaRef<CollectionId>>,
@@ -60,6 +66,7 @@ impl Scanner {
continue;
}
};
#[expect(clippy::filetype_is_file, reason = "we only want regular files")]
if !filetype.is_file() {
yield Item {
path,
+16 -9
View File
@@ -1,10 +1,10 @@
//! The episode scanner will scan a folder and its children
//! 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 flix_model::numbers::{EpisodeNumbers, SeasonNumber};
use async_stream::stream;
use tokio::fs;
@@ -15,18 +15,21 @@ use crate::Error;
use crate::macros::is_image_extension;
use crate::scanner::{EpisodeScan, MediaRef, SeasonScan, episode};
/// A season item
/// A season item.
pub type Item = crate::Item<Scanner>;
/// The scanner for seasons
/// The scanner for seasons.
#[derive(Debug)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner {
/// A scanned season
/// A scanned season.
Season(SeasonScan),
/// A scanned episode
/// A scanned episode.
Episode(EpisodeScan),
}
impl From<episode::Scanner> for Scanner {
#[inline]
fn from(value: episode::Scanner) -> Self {
match value {
episode::Scanner::Episode(e) => Self::Episode(e),
@@ -35,7 +38,11 @@ impl From<episode::Scanner> for Scanner {
}
impl Scanner {
/// Scan a folder for a season and its episodes
/// Scan a folder for a season and its episodes.
#[inline]
#[expect(clippy::allow_attributes, reason = "for tail_expr_drop_order")]
#[allow(tail_expr_drop_order, reason = "bug in stream! macro")]
#[expect(clippy::semicolon_if_nothing_returned, reason = "false positive")]
pub fn scan_season(
path: &Path,
show_ref: MediaRef<ShowId>,
@@ -158,7 +165,7 @@ impl Scanner {
let Ok(episode_numbers) = e_str
.split('E')
.map(|s| s.parse::<EpisodeNumber>())
.map(str::parse)
.collect::<Result<Vec<_>, _>>()
else {
yield Item {
@@ -181,7 +188,7 @@ impl Scanner {
season_number,
episode_numbers,
) {
yield event.map(|e| e.into());
yield event.map(Into::into);
}
}
})
+15 -8
View File
@@ -1,4 +1,4 @@
//! The show scanner will scan a folder and its children
//! The show scanner will scan a folder and its children.
use std::ffi::OsStr;
use std::path::Path;
@@ -15,20 +15,23 @@ use crate::Error;
use crate::macros::is_image_extension;
use crate::scanner::{EpisodeScan, MediaRef, SeasonScan, ShowScan, season};
/// A show item
/// A show item.
pub type Item = crate::Item<Scanner>;
/// The scanner for shows
/// The scanner for shows.
#[derive(Debug)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner {
/// A scanned show
/// A scanned show.
Show(ShowScan),
/// A scanned season
/// A scanned season.
Season(SeasonScan),
/// A scanned episode
/// A scanned episode.
Episode(EpisodeScan),
}
impl From<season::Scanner> for Scanner {
#[inline]
fn from(value: season::Scanner) -> Self {
match value {
season::Scanner::Season(s) => Self::Season(s),
@@ -38,7 +41,11 @@ impl From<season::Scanner> for Scanner {
}
impl Scanner {
/// Scan a folder for a show and its seasons/episodes
/// Scan a folder for a show and its seasons/episodes.
#[inline]
#[expect(clippy::allow_attributes, reason = "for tail_expr_drop_order")]
#[allow(tail_expr_drop_order, reason = "bug in stream! macro")]
#[expect(clippy::semicolon_if_nothing_returned, reason = "false positive")]
pub fn scan_show(
path: &Path,
parent_ref: Option<MediaRef<CollectionId>>,
@@ -143,7 +150,7 @@ impl Scanner {
for await event in
season::Scanner::scan_season(&season_dir, id_ref.clone(), season_number)
{
yield event.map(|e| e.into());
yield event.map(Into::into);
}
}
})