You've already forked flix
Update dependencies and lints
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
[package]
|
||||
name = "flix-mux"
|
||||
version = "0.0.20"
|
||||
license-file.workspace = true
|
||||
|
||||
version = "0.1.0"
|
||||
description = "CLI for bulk media muxing"
|
||||
repository = "https://github.com/QuantumShade/flix"
|
||||
categories = ["command-line-utilities"]
|
||||
repository = "https://git.skrundz.dev/quantumshade/flix"
|
||||
keywords = ["flix"]
|
||||
categories = ["command-line-utilities", "multimedia"]
|
||||
include = ["/src"]
|
||||
publish = true
|
||||
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license-file.workspace = true
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
@@ -31,27 +33,10 @@ clap = { workspace = true, features = [
|
||||
"usage",
|
||||
] }
|
||||
console = { workspace = true }
|
||||
dialoguer = { workspace = true }
|
||||
indicatif = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive", "std"] }
|
||||
serde_json = { workspace = true, features = ["alloc"] }
|
||||
walkdir = { workspace = true }
|
||||
|
||||
[lints.clippy]
|
||||
arithmetic_side_effects = "deny"
|
||||
as_conversions = "deny"
|
||||
checked_conversions = "deny"
|
||||
default_union_representation = "deny"
|
||||
expect_used = "deny"
|
||||
indexing_slicing = "deny"
|
||||
integer_division = "deny"
|
||||
integer_division_remainder_used = "deny"
|
||||
transmute_undefined_repr = "deny"
|
||||
unchecked_time_subtraction = "deny"
|
||||
unwrap_used = "deny"
|
||||
|
||||
[lints.rust]
|
||||
arithmetic_overflow = "forbid"
|
||||
missing_docs = "forbid"
|
||||
unsafe_code = "forbid"
|
||||
unused_doc_comments = "forbid"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+30
-20
@@ -1,17 +1,18 @@
|
||||
use std::path::PathBuf;
|
||||
//! Command line argument parsing.
|
||||
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::parser::Selector;
|
||||
|
||||
#[derive(Parser)]
|
||||
/// Command line argument parser struct.
|
||||
#[derive(Debug, clap::Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct Cli {
|
||||
/// Dry run and print commands
|
||||
pub(crate) struct Args {
|
||||
/// Dry run and print commands.
|
||||
#[arg(short, long)]
|
||||
dry_run: bool,
|
||||
|
||||
/// Stream selectors
|
||||
/// Stream selectors.
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
@@ -21,29 +22,38 @@ pub struct Cli {
|
||||
)]
|
||||
selectors: Vec<Selector>,
|
||||
|
||||
/// The path to the directory to scan
|
||||
/// The path to the directory to scan.
|
||||
#[arg(value_name = "DIR")]
|
||||
scan_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
pub fn is_dry(&self) -> bool {
|
||||
impl Args {
|
||||
/// True if the user requested a dry run.
|
||||
pub(crate) const fn is_dry_run(&self) -> bool {
|
||||
self.dry_run
|
||||
}
|
||||
|
||||
pub fn selectors(&self) -> &[Selector] {
|
||||
/// Returns a slice of provided [Selector].
|
||||
pub(crate) fn selectors(&self) -> &[Selector] {
|
||||
&self.selectors
|
||||
}
|
||||
|
||||
pub fn scan_dir_path(&self) -> PathBuf {
|
||||
fn expect_home_dir() -> PathBuf {
|
||||
#[allow(clippy::expect_used)]
|
||||
std::env::home_dir().expect("you do not have a home directory")
|
||||
}
|
||||
|
||||
match self.scan_dir.strip_prefix("~/") {
|
||||
Ok(path) => expect_home_dir().join(path),
|
||||
Err(_) => self.scan_dir.to_owned(),
|
||||
}
|
||||
/// Get the path to scan.
|
||||
///
|
||||
/// # Panics
|
||||
/// If the user doesn't have a home directory.
|
||||
pub(crate) fn scan_dir_path(&self) -> PathBuf {
|
||||
self.scan_dir.strip_prefix("~/").map_or_else(
|
||||
|_| self.scan_dir.clone(),
|
||||
|path| {
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "without a home directory this program is useless"
|
||||
)]
|
||||
std::env::home_dir()
|
||||
.expect("you do not have a home directory")
|
||||
.join(path)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! flix-mux
|
||||
//! flix-mux.
|
||||
|
||||
use core::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
use clap::Parser as _;
|
||||
use console::style;
|
||||
use indicatif::{HumanBytes, ProgressBar, ProgressStyle};
|
||||
|
||||
@@ -16,14 +16,17 @@ mod parser;
|
||||
mod probe;
|
||||
mod scan;
|
||||
|
||||
#[cfg_attr(test, expect(clippy::missing_panics_doc, reason = "main function"))]
|
||||
#[expect(clippy::print_stderr, reason = "we want to print in a binary")]
|
||||
#[expect(clippy::print_stdout, reason = "we want to print in a binary")]
|
||||
fn main() {
|
||||
let cli = cli::Cli::parse();
|
||||
let cli = cli::Args::parse();
|
||||
|
||||
let (files, size) = scan_directory(
|
||||
&cli.scan_dir_path(),
|
||||
|| {
|
||||
let progress = ProgressBar::new_spinner();
|
||||
progress.set_message(format!("Scanning {:?}", &cli.scan_dir_path()));
|
||||
progress.set_message(format!("Scanning {}", cli.scan_dir_path().display()));
|
||||
progress.enable_steady_tick(Duration::from_millis(50));
|
||||
progress
|
||||
},
|
||||
@@ -31,7 +34,8 @@ fn main() {
|
||||
|len| {
|
||||
let progress = ProgressBar::new(u64::try_from(len).unwrap_or(0));
|
||||
progress.set_style(
|
||||
#[expect(clippy::expect_used)]
|
||||
#[expect(clippy::expect_used, reason = "panic if the template is invalid")]
|
||||
#[expect(clippy::literal_string_with_formatting_args, reason = "template")]
|
||||
ProgressStyle::with_template("[{elapsed_precise}] {wide_bar} {pos}/{len} ({msg})")
|
||||
.expect("static template"),
|
||||
);
|
||||
@@ -40,19 +44,20 @@ fn main() {
|
||||
|progress| progress.inc(1),
|
||||
|progress| progress.finish_and_clear(),
|
||||
|progress, msg| {
|
||||
progress.suspend(|| eprintln!("{} {}", style("[WARN]").bold().yellow(), msg))
|
||||
progress.suspend(|| eprintln!("{} {msg}", style("[WARN]").bold().yellow()));
|
||||
},
|
||||
);
|
||||
println!("Found {} files ({})", files.len(), HumanBytes(size));
|
||||
|
||||
mux_files(
|
||||
cli.is_dry(),
|
||||
cli.is_dry_run(),
|
||||
&files,
|
||||
cli.selectors(),
|
||||
|len| {
|
||||
let progress = ProgressBar::new(u64::try_from(len).unwrap_or(0));
|
||||
progress.set_style(
|
||||
#[expect(clippy::expect_used)]
|
||||
#[expect(clippy::expect_used, reason = "panic if the template is invalid")]
|
||||
#[expect(clippy::literal_string_with_formatting_args, reason = "template")]
|
||||
ProgressStyle::with_template("[{elapsed_precise}] {wide_bar} {pos}/{len} ({msg})")
|
||||
.expect("static template"),
|
||||
);
|
||||
@@ -61,6 +66,6 @@ fn main() {
|
||||
},
|
||||
|progress| progress.inc(1),
|
||||
|progress| progress.finish_with_message("done"),
|
||||
|progress, msg| progress.suspend(|| eprintln!("{} {}", style("[ERR]").bold().red(), msg)),
|
||||
|progress, msg| progress.suspend(|| eprintln!("{} {msg}", style("[ERR]").bold().red())),
|
||||
);
|
||||
}
|
||||
|
||||
+51
-16
@@ -1,42 +1,61 @@
|
||||
//! Model for handling media streams.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Represents a file that was scanned.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MediaFile {
|
||||
pub(crate) struct MediaFile {
|
||||
/// The path to the file.
|
||||
pub path: PathBuf,
|
||||
/// The size of the file.
|
||||
pub byte_size: u64,
|
||||
/// The streams in the file.
|
||||
pub streams: Streams,
|
||||
}
|
||||
|
||||
/// Represents the collection of streams found in a media file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Streams {
|
||||
pub(crate) struct Streams {
|
||||
/// The video streams.
|
||||
pub video: Vec<VideoStream>,
|
||||
/// The audio streams.
|
||||
pub audio: Vec<AudioStream>,
|
||||
/// The subtitle streams.
|
||||
pub subtitle: Vec<SubtitleStream>,
|
||||
}
|
||||
|
||||
pub trait FFStream: serde::de::DeserializeOwned {
|
||||
/// This trait helps with deserializing `ffmpeg` output.
|
||||
pub(crate) trait FFStream: serde::de::DeserializeOwned {
|
||||
/// The type name that `ffmpeg` uses.
|
||||
const FF_TYPE_NAME: &str;
|
||||
}
|
||||
|
||||
/// Represents an individual video stream in a media file.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct VideoStream {
|
||||
pub(crate) struct VideoStream {
|
||||
/// The name of the video codec.
|
||||
codec_name: String,
|
||||
/// Additional tags on the stream.
|
||||
tags: Option<VideoTags>,
|
||||
}
|
||||
|
||||
/// Represents tags on a video stream.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct VideoTags {
|
||||
pub(crate) struct VideoTags {
|
||||
/// The language of the stream.
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
impl VideoStream {
|
||||
pub fn codec(&self) -> &str {
|
||||
/// Get the video codec name.
|
||||
pub(crate) fn codec(&self) -> &str {
|
||||
&self.codec_name
|
||||
}
|
||||
|
||||
pub fn language(&self) -> Option<&str> {
|
||||
/// Get the stream language.
|
||||
pub(crate) fn language(&self) -> Option<&str> {
|
||||
self.tags.as_ref()?.language.as_deref()
|
||||
}
|
||||
}
|
||||
@@ -45,23 +64,30 @@ impl FFStream for VideoStream {
|
||||
const FF_TYPE_NAME: &str = "v";
|
||||
}
|
||||
|
||||
/// Represents an individual audio stream in a media file.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AudioStream {
|
||||
pub(crate) struct AudioStream {
|
||||
/// The name of the audio codec.
|
||||
codec_name: String,
|
||||
/// Additional tags on the stream.
|
||||
tags: Option<AudioTags>,
|
||||
}
|
||||
|
||||
/// Represents tags on an audio stream.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AudioTags {
|
||||
pub(crate) struct AudioTags {
|
||||
/// The language of the stream.
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
impl AudioStream {
|
||||
pub fn codec(&self) -> &str {
|
||||
/// Get the audio codec name.
|
||||
pub(crate) fn codec(&self) -> &str {
|
||||
&self.codec_name
|
||||
}
|
||||
|
||||
pub fn language(&self) -> Option<&str> {
|
||||
/// Get the stream language.
|
||||
pub(crate) fn language(&self) -> Option<&str> {
|
||||
self.tags.as_ref()?.language.as_deref()
|
||||
}
|
||||
}
|
||||
@@ -70,28 +96,37 @@ impl FFStream for AudioStream {
|
||||
const FF_TYPE_NAME: &str = "a";
|
||||
}
|
||||
|
||||
/// Represents an individual subtitle stream in a media file.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SubtitleStream {
|
||||
pub(crate) struct SubtitleStream {
|
||||
/// The name of the subtitle codec.
|
||||
codec_name: String,
|
||||
/// Additional tags on the stream.
|
||||
tags: Option<SubtitleTags>,
|
||||
}
|
||||
|
||||
/// Represents tags on a subtitle stream.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SubtitleTags {
|
||||
pub(crate) struct SubtitleTags {
|
||||
/// The language of the stream.
|
||||
language: Option<String>,
|
||||
/// The title of the stream.
|
||||
title: Option<String>,
|
||||
}
|
||||
|
||||
impl SubtitleStream {
|
||||
pub fn codec(&self) -> &str {
|
||||
/// Get the subtitle codec name.
|
||||
pub(crate) fn codec(&self) -> &str {
|
||||
&self.codec_name
|
||||
}
|
||||
|
||||
pub fn language(&self) -> Option<&str> {
|
||||
/// Get the stream language.
|
||||
pub(crate) fn language(&self) -> Option<&str> {
|
||||
self.tags.as_ref()?.language.as_deref()
|
||||
}
|
||||
|
||||
pub fn title(&self) -> Option<&str> {
|
||||
/// Get the stream title.
|
||||
pub(crate) fn title(&self) -> Option<&str> {
|
||||
self.tags.as_ref()?.title.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
+77
-39
@@ -1,3 +1,5 @@
|
||||
//! Wrapper around `ffmpeg` to mux media files.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
@@ -5,14 +7,19 @@ use anyhow::{Context as _, Result};
|
||||
use crate::model::MediaFile;
|
||||
use crate::parser::{Matcher, Selector, StreamFlag, StreamType};
|
||||
|
||||
/// Helper struct for tracking which output index is currently available.
|
||||
#[derive(Default)]
|
||||
struct OutputIndex {
|
||||
/// The next free video index.
|
||||
video: usize,
|
||||
/// The next free audio index.
|
||||
audio: usize,
|
||||
/// The next free subtitle index.
|
||||
subtitle: usize,
|
||||
}
|
||||
|
||||
pub fn mux_files<T>(
|
||||
/// For each input file, apply all selectors and mux the selection.
|
||||
pub(crate) fn mux_files<T>(
|
||||
dry_run: bool,
|
||||
files: &[MediaFile],
|
||||
selectors: &[Selector],
|
||||
@@ -24,14 +31,20 @@ pub fn mux_files<T>(
|
||||
let mut progress = fixed_length_start(files.len());
|
||||
for file in files {
|
||||
if let Err(err) = mux(dry_run, file, selectors) {
|
||||
print_fn(&progress, &format!("{:?}", err));
|
||||
print_fn(&progress, &format!("{err:?}"));
|
||||
}
|
||||
fixed_length_update(&mut progress);
|
||||
}
|
||||
fixed_length_end(progress);
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
/// Apply all selectors and mux the file.
|
||||
///
|
||||
/// # Errors
|
||||
/// If the call to `ffmpeg` fails.
|
||||
///
|
||||
/// # Panics
|
||||
/// If the system fails to allocate a temporary file.
|
||||
fn mux(dry_run: bool, file: &MediaFile, selectors: &[Selector]) -> Result<()> {
|
||||
let mut command = Command::new("ffmpeg");
|
||||
let mut command = command.args(["-v", "error"]);
|
||||
@@ -40,10 +53,14 @@ fn mux(dry_run: bool, file: &MediaFile, selectors: &[Selector]) -> Result<()> {
|
||||
command = command.arg(file.path.as_os_str());
|
||||
|
||||
for selector in selectors {
|
||||
command = command.args(
|
||||
make_map_args(file, selector)
|
||||
.with_context(|| format!("Failed to mux {:?}", file.path))?,
|
||||
);
|
||||
command = command.args(make_map_args(file, selector).with_context(|| {
|
||||
let info = if dry_run {
|
||||
format!("Streams:\n{file:#?}")
|
||||
} else {
|
||||
String::from("use -d for stream info")
|
||||
};
|
||||
format!("Failed to mux {}\n{info}", file.path.display())
|
||||
})?);
|
||||
}
|
||||
|
||||
command = command.args(["-c:v", "copy", "-c:a", "copy", "-c:s", "mov_text"]);
|
||||
@@ -56,25 +73,34 @@ fn mux(dry_run: bool, file: &MediaFile, selectors: &[Selector]) -> Result<()> {
|
||||
let mut ouput_index = OutputIndex::default();
|
||||
for selector in selectors {
|
||||
command = command.args(
|
||||
make_metadata_args(file, selector, &mut ouput_index)
|
||||
.with_context(|| format!("Failed to mux {:?}", file.path))?,
|
||||
make_metadata_args(file, selector, &mut ouput_index).with_context(|| {
|
||||
let info = if dry_run {
|
||||
format!("Streams:\n{file:#?}")
|
||||
} else {
|
||||
String::from("use -d for stream info")
|
||||
};
|
||||
format!("Failed to mux {}\n{info}", file.path.display())
|
||||
})?,
|
||||
);
|
||||
}
|
||||
|
||||
let temp_path = file.path.with_extension("mp4");
|
||||
command = command.arg(temp_path.file_name().expect("file name exists"));
|
||||
#[expect(clippy::expect_used, reason = "files must have names")]
|
||||
{
|
||||
command = command.arg(temp_path.file_name().expect("file name exists"));
|
||||
}
|
||||
|
||||
if dry_run {
|
||||
print_command(command);
|
||||
} else {
|
||||
let output = command
|
||||
.output()
|
||||
.with_context(|| format!("Failed to mux {:?}", file.path))?;
|
||||
.with_context(|| format!("Failed to mux {}", file.path.display()))?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"ffmpeg failed for {:?}:\n\n{}",
|
||||
file.path,
|
||||
"ffmpeg failed for {}:\n\n{}",
|
||||
file.path.display(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
@@ -83,23 +109,31 @@ fn mux(dry_run: bool, file: &MediaFile, selectors: &[Selector]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate the `-map` argument list for `ffmpeg`.
|
||||
///
|
||||
/// # Errors
|
||||
/// If the stream selection is unsatisfyable for the file.
|
||||
fn make_map_args(file: &MediaFile, selector: &Selector) -> Result<Vec<String>> {
|
||||
let source_index = 0;
|
||||
let source_index: u32 = 0;
|
||||
let stream_type = selector.stream_type.as_ref();
|
||||
let Some(stream_index) = find_stream_index(file, selector) else {
|
||||
if selector.optional {
|
||||
return Ok(vec![]);
|
||||
} else {
|
||||
anyhow::bail!("unsatisfied stream selection");
|
||||
}
|
||||
|
||||
anyhow::bail!("unsatisfied stream selection");
|
||||
};
|
||||
|
||||
Ok(vec![
|
||||
String::from("-map"),
|
||||
format!("{}:{}:{}", source_index, stream_type, stream_index),
|
||||
format!("{source_index}:{stream_type}:{stream_index}"),
|
||||
])
|
||||
}
|
||||
|
||||
/// Generate the `-metadata` argument list for `ffmpeg`.
|
||||
///
|
||||
/// # Errors
|
||||
/// If the stream selection is unsatisfyable for the file.
|
||||
fn make_metadata_args(
|
||||
file: &MediaFile,
|
||||
selector: &Selector,
|
||||
@@ -112,9 +146,9 @@ fn make_metadata_args(
|
||||
let Some(_) = find_stream_index(file, selector) else {
|
||||
if selector.optional {
|
||||
return Ok(vec![]);
|
||||
} else {
|
||||
anyhow::bail!("unsatisfied stream selection");
|
||||
}
|
||||
|
||||
anyhow::bail!("unsatisfied stream selection");
|
||||
};
|
||||
|
||||
let counter = match selector.stream_type {
|
||||
@@ -126,8 +160,8 @@ fn make_metadata_args(
|
||||
*counter = counter.saturating_add(1);
|
||||
|
||||
let mut args = vec![
|
||||
format!("-metadata:s:{}:{}", stream_type, stream_index),
|
||||
format!("language={}", stream_language),
|
||||
format!("-metadata:s:{stream_type}:{stream_index}"),
|
||||
format!("language={stream_language}"),
|
||||
];
|
||||
|
||||
if selector.stream_type == StreamType::Subtitle {
|
||||
@@ -137,25 +171,26 @@ fn make_metadata_args(
|
||||
None => match stream_language {
|
||||
"eng" => "English",
|
||||
"jpn" => "Japanese",
|
||||
_ => anyhow::bail!("Unhandled subtitle language: {}", stream_language),
|
||||
_ => anyhow::bail!("Unhandled subtitle language: {stream_language}"),
|
||||
},
|
||||
};
|
||||
|
||||
args.push(format!("-metadata:s:s:{}", stream_index));
|
||||
args.push(format!("title={}", sub_title));
|
||||
args.push(format!("-metadata:s:s:{stream_index}"));
|
||||
args.push(format!("title={sub_title}"));
|
||||
}
|
||||
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
/// Walk the streams of a file to match the given [Selector].
|
||||
fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
|
||||
let needs_forced = selector.flag == Some(StreamFlag::Forced);
|
||||
let needs_sdh = selector.flag == Some(StreamFlag::Sdh);
|
||||
|
||||
match selector.stream_type {
|
||||
StreamType::Video => match selector.matcher {
|
||||
Matcher::Index(index) => (file.streams.video.len() > index).then_some(index),
|
||||
Matcher::Language(ref language) => file
|
||||
StreamType::Video => match &selector.matcher {
|
||||
Matcher::Index(index) => (file.streams.video.len() > *index).then_some(*index),
|
||||
Matcher::Language(language) => file
|
||||
.streams
|
||||
.video
|
||||
.iter()
|
||||
@@ -163,7 +198,7 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
|
||||
.filter(|(_, c)| c.language() == Some(language.as_str()))
|
||||
.map(|(i, _)| i)
|
||||
.next(),
|
||||
Matcher::Codec(ref codec) => file
|
||||
Matcher::Codec(codec) => file
|
||||
.streams
|
||||
.video
|
||||
.iter()
|
||||
@@ -172,9 +207,9 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
|
||||
.map(|(i, _)| i)
|
||||
.next(),
|
||||
},
|
||||
StreamType::Audio => match selector.matcher {
|
||||
Matcher::Index(index) => (file.streams.audio.len() > index).then_some(index),
|
||||
Matcher::Language(ref language) => file
|
||||
StreamType::Audio => match &selector.matcher {
|
||||
Matcher::Index(index) => (file.streams.audio.len() > *index).then_some(*index),
|
||||
Matcher::Language(language) => file
|
||||
.streams
|
||||
.audio
|
||||
.iter()
|
||||
@@ -182,7 +217,7 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
|
||||
.filter(|(_, c)| c.language() == Some(language.as_str()))
|
||||
.map(|(i, _)| i)
|
||||
.next(),
|
||||
Matcher::Codec(ref codec) => file
|
||||
Matcher::Codec(codec) => file
|
||||
.streams
|
||||
.audio
|
||||
.iter()
|
||||
@@ -191,9 +226,9 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
|
||||
.map(|(i, _)| i)
|
||||
.next(),
|
||||
},
|
||||
StreamType::Subtitle => match selector.matcher {
|
||||
Matcher::Index(index) => (file.streams.subtitle.len() > index).then_some(index),
|
||||
Matcher::Language(ref language) => {
|
||||
StreamType::Subtitle => match &selector.matcher {
|
||||
Matcher::Index(index) => (file.streams.subtitle.len() > *index).then_some(*index),
|
||||
Matcher::Language(language) => {
|
||||
file.streams
|
||||
.subtitle
|
||||
.iter()
|
||||
@@ -214,7 +249,7 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
|
||||
.map(|(i, _)| i)
|
||||
.next()
|
||||
}
|
||||
Matcher::Codec(ref codec) => file
|
||||
Matcher::Codec(codec) => file
|
||||
.streams
|
||||
.subtitle
|
||||
.iter()
|
||||
@@ -226,6 +261,8 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Print out the command to stdout.
|
||||
#[expect(clippy::print_stdout, reason = "the purpose is to print")]
|
||||
fn print_command(cmd: &Command) {
|
||||
let program = cmd.get_program().to_string_lossy();
|
||||
|
||||
@@ -235,15 +272,16 @@ fn print_command(cmd: &Command) {
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
println!("{} {}", program, args);
|
||||
println!("{program} {args}");
|
||||
}
|
||||
|
||||
/// Escape a string to be used by a shell.
|
||||
fn shell_escape(s: &str) -> String {
|
||||
if s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c))
|
||||
{
|
||||
s.to_string()
|
||||
s.to_owned()
|
||||
} else {
|
||||
format!("{:?}", s)
|
||||
format!("{s:?}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
//! Parsing types for `ffmpeg` output.
|
||||
|
||||
use core::error::Error;
|
||||
use core::fmt;
|
||||
use core::str::FromStr;
|
||||
|
||||
/// The type of a stream.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StreamType {
|
||||
pub(crate) enum StreamType {
|
||||
/// A video stream.
|
||||
Video,
|
||||
/// An audio stream.
|
||||
Audio,
|
||||
/// A subtitle stream.
|
||||
Subtitle,
|
||||
}
|
||||
|
||||
impl AsRef<str> for StreamType {
|
||||
fn as_ref(&self) -> &str {
|
||||
match self {
|
||||
StreamType::Video => "v",
|
||||
StreamType::Audio => "a",
|
||||
StreamType::Subtitle => "s",
|
||||
Self::Video => "v",
|
||||
Self::Audio => "a",
|
||||
Self::Subtitle => "s",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,18 +30,22 @@ impl FromStr for StreamType {
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"v" => Ok(StreamType::Video),
|
||||
"a" => Ok(StreamType::Audio),
|
||||
"s" => Ok(StreamType::Subtitle),
|
||||
other => Err(ParseSelectorError::InvalidStreamType(other.to_string())),
|
||||
"v" => Ok(Self::Video),
|
||||
"a" => Ok(Self::Audio),
|
||||
"s" => Ok(Self::Subtitle),
|
||||
other => Err(ParseSelectorError::InvalidStreamType(other.to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A property of a stream to match on.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Matcher {
|
||||
pub(crate) enum Matcher {
|
||||
/// Match a specific stream index.
|
||||
Index(usize),
|
||||
/// Match the first stream of the given language.
|
||||
Language(String),
|
||||
/// Match the first stream of the given codec.
|
||||
Codec(String),
|
||||
}
|
||||
|
||||
@@ -44,19 +54,22 @@ impl FromStr for Matcher {
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
if let Ok(idx) = s.parse::<usize>() {
|
||||
return Ok(Matcher::Index(idx));
|
||||
return Ok(Self::Index(idx));
|
||||
}
|
||||
|
||||
if s.len() == 3 {
|
||||
return Ok(Matcher::Language(s.to_ascii_lowercase()));
|
||||
return Ok(Self::Language(s.to_ascii_lowercase()));
|
||||
}
|
||||
Ok(Matcher::Codec(s.to_ascii_lowercase()))
|
||||
Ok(Self::Codec(s.to_ascii_lowercase()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Flags applied to streams.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StreamFlag {
|
||||
pub(crate) enum StreamFlag {
|
||||
/// Forced flag.
|
||||
Forced,
|
||||
/// SDH flag.
|
||||
Sdh,
|
||||
}
|
||||
|
||||
@@ -65,24 +78,31 @@ impl FromStr for StreamFlag {
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"forced" => Ok(StreamFlag::Forced),
|
||||
"sdh" => Ok(StreamFlag::Sdh),
|
||||
other => Err(ParseSelectorError::InvalidFlag(other.to_string())),
|
||||
"forced" => Ok(Self::Forced),
|
||||
"sdh" => Ok(Self::Sdh),
|
||||
other => Err(ParseSelectorError::InvalidFlag(other.to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete selector which has everything it needs to find a specific stream.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Selector {
|
||||
pub(crate) struct Selector {
|
||||
/// The type of the stream to select.
|
||||
pub stream_type: StreamType,
|
||||
/// The matcher to use when selecting.
|
||||
pub matcher: Matcher,
|
||||
/// A desired flag.
|
||||
pub flag: Option<StreamFlag>,
|
||||
/// Whether or not to bail if the stream cannot be found.
|
||||
pub optional: bool,
|
||||
/// The output language of the stream.
|
||||
pub out_lang: String,
|
||||
}
|
||||
|
||||
impl Selector {
|
||||
pub fn language(&self) -> &str {
|
||||
/// Get the target language of the selector.
|
||||
pub(crate) fn language(&self) -> &str {
|
||||
&self.out_lang
|
||||
}
|
||||
}
|
||||
@@ -125,7 +145,7 @@ impl FromStr for Selector {
|
||||
(true, _) => return Err(ParseSelectorError::UnspecifiedLanguage),
|
||||
};
|
||||
|
||||
Ok(Selector {
|
||||
Ok(Self {
|
||||
stream_type,
|
||||
matcher,
|
||||
flag,
|
||||
@@ -135,15 +155,22 @@ impl FromStr for Selector {
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can arise when parsing a selector string.
|
||||
#[derive(Debug)]
|
||||
pub enum ParseSelectorError {
|
||||
pub(crate) enum ParseSelectorError {
|
||||
/// The selector string is empty.
|
||||
Empty,
|
||||
/// The selector string has an invalid format.
|
||||
InvalidFormat,
|
||||
/// The requested stream type in invalid.
|
||||
InvalidStreamType(String),
|
||||
/// The requested flag is invalid.
|
||||
InvalidFlag(String),
|
||||
/// The target language is unspecified.
|
||||
UnspecifiedLanguage,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl Error for ParseSelectorError {}
|
||||
|
||||
impl fmt::Display for ParseSelectorError {
|
||||
@@ -151,8 +178,8 @@ impl fmt::Display for ParseSelectorError {
|
||||
match self {
|
||||
Self::Empty => write!(f, "selector was empty"),
|
||||
Self::InvalidFormat => write!(f, "invalid selector format"),
|
||||
Self::InvalidStreamType(s) => write!(f, "invalid stream type '{}'", s),
|
||||
Self::InvalidFlag(s) => write!(f, "invalid stream flag '{}'", s),
|
||||
Self::InvalidStreamType(s) => write!(f, "invalid stream type '{s}'"),
|
||||
Self::InvalidFlag(s) => write!(f, "invalid stream flag '{s}'"),
|
||||
Self::UnspecifiedLanguage => write!(f, "unspecified output language"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
//! Helpers for using `ffprobe` to gather stream information from media files.
|
||||
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
@@ -7,11 +9,20 @@ use walkdir::DirEntry;
|
||||
|
||||
use crate::model::{FFStream, MediaFile, Streams};
|
||||
|
||||
/// The top-level containing type for `ffprobe` output.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FFProbeOutput<S> {
|
||||
/// The list of returned streams.
|
||||
streams: Vec<S>,
|
||||
}
|
||||
|
||||
/// Calls `ffprobe` on a [`DirEntry`] to gather one type of stream.
|
||||
///
|
||||
/// # Errors
|
||||
/// Forwards any IO or parsing errors.
|
||||
///
|
||||
/// # Panics
|
||||
/// If the file path is not utf8.
|
||||
fn probe_file_streams<S: FFStream>(entry: &DirEntry) -> Result<Vec<S>> {
|
||||
let output = Command::new("ffprobe")
|
||||
.args([
|
||||
@@ -22,16 +33,16 @@ fn probe_file_streams<S: FFStream>(entry: &DirEntry) -> Result<Vec<S>> {
|
||||
"-show_streams",
|
||||
"-select_streams",
|
||||
S::FF_TYPE_NAME,
|
||||
#[expect(clippy::expect_used)]
|
||||
#[expect(clippy::expect_used, reason = "panic if path is not utf8")]
|
||||
entry.path().to_str().expect("path should be utf8"),
|
||||
])
|
||||
.output()
|
||||
.with_context(|| format!("Failed to run ffprobe on {:?}", entry.path()))?;
|
||||
.with_context(|| format!("Failed to run ffprobe on {}", entry.path().display()))?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"ffprobe failed for {:?}:\n\n{}",
|
||||
entry.path(),
|
||||
"ffprobe failed for {}:\n\n{}",
|
||||
entry.path().display(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
@@ -40,7 +51,11 @@ fn probe_file_streams<S: FFStream>(entry: &DirEntry) -> Result<Vec<S>> {
|
||||
Ok(parsed.streams)
|
||||
}
|
||||
|
||||
pub fn probe_file(entry: &DirEntry) -> Result<MediaFile> {
|
||||
/// Calls `ffprobe` on a [`DirEntry`] to detect all streams.
|
||||
///
|
||||
/// # Errors
|
||||
/// Forwards any IO or parsing errors.
|
||||
pub(crate) fn probe_file(entry: &DirEntry) -> Result<MediaFile> {
|
||||
Ok(MediaFile {
|
||||
path: entry.path().to_path_buf(),
|
||||
byte_size: entry.metadata()?.size(),
|
||||
|
||||
+12
-15
@@ -1,3 +1,5 @@
|
||||
//! Helpers for filesystem scanning.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use walkdir::WalkDir;
|
||||
@@ -5,7 +7,8 @@ use walkdir::WalkDir;
|
||||
use crate::model::MediaFile;
|
||||
use crate::probe::probe_file;
|
||||
|
||||
pub fn scan_directory<T>(
|
||||
/// Recursively scan a directory.
|
||||
pub(crate) fn scan_directory<T>(
|
||||
path: &Path,
|
||||
unknown_length_start: impl FnOnce() -> T,
|
||||
unknown_length_end: impl FnOnce(T),
|
||||
@@ -18,32 +21,26 @@ pub fn scan_directory<T>(
|
||||
let files: Vec<_> = WalkDir::new(path)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|e| e.file_type().is_file())
|
||||
.filter(|e| {
|
||||
let is_mkv = e
|
||||
.path()
|
||||
.extension()
|
||||
.unwrap_or_default()
|
||||
.eq_ignore_ascii_case("mkv");
|
||||
let is_mp4 = e
|
||||
.path()
|
||||
.extension()
|
||||
.unwrap_or_default()
|
||||
.eq_ignore_ascii_case("mp4");
|
||||
is_mkv || is_mp4
|
||||
#[expect(clippy::filetype_is_file, reason = "we only want regular files")]
|
||||
e.file_type().is_file()
|
||||
})
|
||||
.filter(|e| {
|
||||
let ext = e.path().extension().unwrap_or_default();
|
||||
ext.eq_ignore_ascii_case("mkv") || ext.eq_ignore_ascii_case("mp4")
|
||||
})
|
||||
.collect();
|
||||
unknown_length_end(spinner);
|
||||
|
||||
let mut progress = fixed_length_start(files.len());
|
||||
let mut total_byte_size = 0u64;
|
||||
let mut total_byte_size = 0_u64;
|
||||
let files: Vec<_> = files
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let file = match probe_file(entry) {
|
||||
Ok(file) => Some(file),
|
||||
Err(err) => {
|
||||
print_fn(&progress, &format!("{:?}", err));
|
||||
print_fn(&progress, &format!("{err:?}"));
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
+9
-24
@@ -1,14 +1,16 @@
|
||||
[package]
|
||||
name = "flix-cli"
|
||||
version = "0.0.20"
|
||||
license-file.workspace = true
|
||||
|
||||
version = "0.1.0"
|
||||
description = "CLI for interacting with a flix database"
|
||||
repository = "https://github.com/QuantumShade/flix"
|
||||
categories = ["command-line-utilities"]
|
||||
repository = "https://git.skrundz.dev/quantumshade/flix"
|
||||
keywords = ["flix"]
|
||||
categories = ["command-line-utilities", "multimedia"]
|
||||
include = ["/src"]
|
||||
publish = true
|
||||
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license-file.workspace = true
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
@@ -32,7 +34,6 @@ clap = { workspace = true, features = [
|
||||
"usage",
|
||||
] }
|
||||
flix = { workspace = true, features = ["tmdb"] }
|
||||
futures = { workspace = true }
|
||||
sea-orm = { workspace = true, features = ["debug-print", "runtime-tokio"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
tokio = { workspace = true, features = ["fs", "macros", "rt"] }
|
||||
@@ -40,21 +41,5 @@ toml = { workspace = true, features = ["parse", "serde"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[lints.clippy]
|
||||
arithmetic_side_effects = "deny"
|
||||
as_conversions = "deny"
|
||||
checked_conversions = "deny"
|
||||
default_union_representation = "deny"
|
||||
expect_used = "deny"
|
||||
indexing_slicing = "deny"
|
||||
integer_division = "deny"
|
||||
integer_division_remainder_used = "deny"
|
||||
transmute_undefined_repr = "deny"
|
||||
unchecked_time_subtraction = "deny"
|
||||
unwrap_used = "deny"
|
||||
|
||||
[lints.rust]
|
||||
arithmetic_overflow = "forbid"
|
||||
missing_docs = "forbid"
|
||||
unsafe_code = "forbid"
|
||||
unused_doc_comments = "forbid"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
//! Command line argument parsing for the `flix` subcommand.
|
||||
|
||||
use flix::model::numbers::{EpisodeNumber, SeasonNumber};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use clap::Subcommand;
|
||||
|
||||
/// Subcommand for adding `flix` media.
|
||||
#[derive(Subcommand)]
|
||||
pub enum AddCommand {
|
||||
/// Add a flix collection
|
||||
pub(crate) enum AddCommand {
|
||||
/// Add a flix collection.
|
||||
Collection {
|
||||
/// The collection's title.
|
||||
#[arg(value_name = "TITLE")]
|
||||
title: String,
|
||||
/// The collection's overview.
|
||||
#[arg(value_name = "OVERVIEW")]
|
||||
overview: String,
|
||||
},
|
||||
/// Add a flix episode
|
||||
/// Add a flix episode.
|
||||
Episode {
|
||||
/// The episode's show's web slug.
|
||||
#[arg(value_name = "SHOW_WEB_SLUG")]
|
||||
show_slug: String,
|
||||
show_web_slug: String,
|
||||
/// The episode's season number.
|
||||
#[arg(value_name = "NUMBER")]
|
||||
season_number: SeasonNumber,
|
||||
/// The episode's number.
|
||||
#[arg(value_name = "NUMBER")]
|
||||
episode_number: EpisodeNumber,
|
||||
/// The episode's title.
|
||||
#[arg(value_name = "TITLE")]
|
||||
title: String,
|
||||
/// The episode's overview.
|
||||
#[arg(value_name = "OVERVIEW")]
|
||||
overview: String,
|
||||
/// The episode's air date.
|
||||
#[arg(value_name = "DATE")]
|
||||
air_date: NaiveDate,
|
||||
},
|
||||
|
||||
+68
-52
@@ -1,15 +1,18 @@
|
||||
//! Command line argument parsing.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
|
||||
pub mod flix;
|
||||
pub mod tmdb;
|
||||
pub(crate) mod flix;
|
||||
pub(crate) mod tmdb;
|
||||
|
||||
/// Command line argument parser struct.
|
||||
#[derive(Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct Cli {
|
||||
/// Use a custom config file
|
||||
pub(crate) struct Cli {
|
||||
/// Use a custom config file.
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
@@ -17,41 +20,53 @@ pub struct Cli {
|
||||
default_value = "~/.config/flix/config.toml"
|
||||
)]
|
||||
config: PathBuf,
|
||||
|
||||
/// Use a custom cache file
|
||||
/// Use a custom cache file.
|
||||
#[arg(short = 'C', long, value_name = "FILE", default_value = "./flix.redb")]
|
||||
cache: PathBuf,
|
||||
|
||||
/// Use a custom database file
|
||||
/// Use a custom database file.
|
||||
#[arg(short, long, value_name = "FILE", default_value = "./flix.db")]
|
||||
database: PathBuf,
|
||||
|
||||
/// Enable tracing
|
||||
/// Enable tracing.
|
||||
#[arg(short, long)]
|
||||
pub trace: bool,
|
||||
|
||||
trace: bool,
|
||||
/// Subcommand to execute.
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
pub fn config_path(&self) -> PathBuf {
|
||||
fn expect_home_dir() -> PathBuf {
|
||||
#[allow(clippy::expect_used)]
|
||||
std::env::home_dir().expect("you do not have a home directory")
|
||||
}
|
||||
|
||||
match self.config.strip_prefix("~/") {
|
||||
Ok(path) => expect_home_dir().join(path),
|
||||
Err(_) => self.config.to_owned(),
|
||||
}
|
||||
/// Get the config file path.
|
||||
///
|
||||
/// # Panics
|
||||
/// If the user doesn't have a home directory.
|
||||
#[inline]
|
||||
pub(crate) fn config_path(&self) -> PathBuf {
|
||||
self.config.strip_prefix("~/").map_or_else(
|
||||
|_| self.config.clone(),
|
||||
|path| {
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "without a home directory this program is useless"
|
||||
)]
|
||||
std::env::home_dir()
|
||||
.expect("you do not have a home directory")
|
||||
.join(path)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn cache_path(&self) -> &Path {
|
||||
/// Get the path to the cache file.
|
||||
#[inline]
|
||||
pub(crate) fn cache_path(&self) -> &Path {
|
||||
&self.cache
|
||||
}
|
||||
|
||||
pub fn database_path(&self) -> Result<String> {
|
||||
/// Get the path to the destination database.
|
||||
///
|
||||
/// # Errors
|
||||
/// If the database path is not utf8.
|
||||
#[inline]
|
||||
pub(crate) fn database_path(&self) -> Result<String> {
|
||||
self.database
|
||||
.as_os_str()
|
||||
.to_str()
|
||||
@@ -59,77 +74,78 @@ impl Cli {
|
||||
.ok_or_else(|| anyhow!(".as_os_str().to_str()"))
|
||||
}
|
||||
|
||||
pub fn command(self) -> Command {
|
||||
/// Get whether or not the trace flag was enabled.
|
||||
#[inline]
|
||||
pub(crate) const fn trace(&self) -> bool {
|
||||
self.trace
|
||||
}
|
||||
|
||||
/// Get the subcommand to run.
|
||||
#[inline]
|
||||
pub(crate) fn get_command(self) -> Command {
|
||||
self.command
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional overrides for media information.
|
||||
#[derive(Args)]
|
||||
pub struct AddOverrides {
|
||||
pub(crate) struct AddOverrides {
|
||||
/// Override the displayed title.
|
||||
#[arg(long)]
|
||||
pub title: Option<String>,
|
||||
/// Override the title used to sort.
|
||||
#[arg(long)]
|
||||
pub sort_title: Option<String>,
|
||||
/// Override the filesystem slug.
|
||||
#[arg(long)]
|
||||
pub fs_slug: Option<String>,
|
||||
/// Overrride the web slug.
|
||||
#[arg(long)]
|
||||
pub web_slug: Option<String>,
|
||||
}
|
||||
|
||||
/// Top level cli commands.
|
||||
#[derive(Subcommand)]
|
||||
pub enum Command {
|
||||
/// Initialize a new database
|
||||
pub(crate) enum Command {
|
||||
/// Initialize a new database.
|
||||
Init,
|
||||
/// Add new items to the database
|
||||
/// Add new items to the database.
|
||||
Add {
|
||||
/// Overrides.
|
||||
#[command(flatten)]
|
||||
overrides: AddOverrides,
|
||||
/// Command.
|
||||
#[command(subcommand)]
|
||||
command: AddCommand,
|
||||
},
|
||||
/// Update an existing item in the database
|
||||
Update {
|
||||
#[command(subcommand)]
|
||||
command: UpdateCommand,
|
||||
},
|
||||
}
|
||||
|
||||
/// Wrapper for `add` around different backends.
|
||||
#[derive(Subcommand)]
|
||||
pub enum AddCommand {
|
||||
/// Use the flix backend
|
||||
pub(crate) enum AddCommand {
|
||||
/// Use the flix backend.
|
||||
Flix {
|
||||
/// Command backend.
|
||||
#[command(subcommand)]
|
||||
command: flix::AddCommand,
|
||||
},
|
||||
/// Use the TMDB backend
|
||||
/// Use the TMDB backend.
|
||||
Tmdb {
|
||||
/// Command backend.
|
||||
#[command(subcommand)]
|
||||
command: tmdb::Command,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<flix::AddCommand> for AddCommand {
|
||||
#[inline]
|
||||
fn from(value: flix::AddCommand) -> Self {
|
||||
Self::Flix { command: value }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tmdb::Command> for AddCommand {
|
||||
fn from(value: tmdb::Command) -> Self {
|
||||
Self::Tmdb { command: value }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum UpdateCommand {
|
||||
/// Use the TMDB backend
|
||||
Tmdb {
|
||||
#[command(subcommand)]
|
||||
command: tmdb::Command,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<tmdb::Command> for UpdateCommand {
|
||||
#[inline]
|
||||
fn from(value: tmdb::Command) -> Self {
|
||||
Self::Tmdb { command: value }
|
||||
}
|
||||
|
||||
+24
-12
@@ -1,41 +1,53 @@
|
||||
//! Command line argument parsing for the `tmdb` subcommand.
|
||||
|
||||
use flix::model::numbers::{EpisodeNumber, SeasonNumber};
|
||||
use flix::tmdb::model::id::RawId;
|
||||
use flix::tmdb::model::id::TmdbRepr;
|
||||
|
||||
use clap::Subcommand;
|
||||
|
||||
/// Subcommand for adding `tmdb` media.
|
||||
#[derive(Subcommand)]
|
||||
pub enum Command {
|
||||
/// Process a TMDB collection
|
||||
pub(crate) enum Command {
|
||||
/// Process a TMDB collection.
|
||||
Collection {
|
||||
/// The collection's ID.
|
||||
#[arg(value_name = "TMDB_ID")]
|
||||
id: RawId,
|
||||
id: TmdbRepr,
|
||||
},
|
||||
/// Process a TMDB movie
|
||||
/// Process a TMDB movie.
|
||||
Movie {
|
||||
/// The movie's ID.
|
||||
#[arg(value_name = "TMDB_ID")]
|
||||
id: RawId,
|
||||
id: TmdbRepr,
|
||||
},
|
||||
/// Process a TMDB show
|
||||
/// Process a TMDB show.
|
||||
Show {
|
||||
/// The show's ID.
|
||||
#[arg(value_name = "TMDB_ID")]
|
||||
id: RawId,
|
||||
id: TmdbRepr,
|
||||
},
|
||||
/// Process a TMDB season
|
||||
/// Process a TMDB season.
|
||||
Season {
|
||||
/// The season's show's ID.
|
||||
#[arg(value_name = "TMDB_ID")]
|
||||
id: RawId,
|
||||
id: TmdbRepr,
|
||||
/// The season's number.
|
||||
#[arg(value_name = "SEASON_NUM")]
|
||||
season: SeasonNumber,
|
||||
},
|
||||
/// Process a TMDB episode
|
||||
/// Process a TMDB episode.
|
||||
#[command(trailing_var_arg = true)]
|
||||
Episode {
|
||||
/// The episode's show's ID.
|
||||
#[arg(value_name = "TMDB_ID")]
|
||||
id: RawId,
|
||||
id: TmdbRepr,
|
||||
/// The episode's season's number.
|
||||
#[arg(value_name = "SEASON_NUM")]
|
||||
season: SeasonNumber,
|
||||
/// The episode's number.
|
||||
#[arg(value_name = "EPISODE_NUM")]
|
||||
episode: EpisodeNumber,
|
||||
/// Additional episode numbers for merged media files.
|
||||
#[arg(value_name = "...")]
|
||||
episodes: Vec<EpisodeNumber>,
|
||||
},
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
//! CLI configuration.
|
||||
|
||||
/// Top level config struct.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct Config {
|
||||
pub(crate) struct Config {
|
||||
/// The TMDB config.
|
||||
tmdb: TmdbConfig,
|
||||
}
|
||||
|
||||
/// TMDB config struct.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct TmdbConfig {
|
||||
pub(crate) struct TmdbConfig {
|
||||
/// The bearer token to use for API requests.
|
||||
bearer_token: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn tmdb(&self) -> &TmdbConfig {
|
||||
/// Get the TMDB config.
|
||||
pub(crate) const fn tmdb(&self) -> &TmdbConfig {
|
||||
&self.tmdb
|
||||
}
|
||||
}
|
||||
|
||||
impl TmdbConfig {
|
||||
pub fn bearer_token(&self) -> &str {
|
||||
/// Get the bearer token.
|
||||
pub(crate) fn bearer_token(&self) -> &str {
|
||||
&self.bearer_token
|
||||
}
|
||||
}
|
||||
|
||||
+22
-6
@@ -1,9 +1,17 @@
|
||||
//! Databse helpers.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use flix::db::connection::Connection;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use sea_orm::{ConnectOptions, Database};
|
||||
use tokio::fs;
|
||||
|
||||
/// Connect to a database using a connection string.
|
||||
///
|
||||
/// # Errors
|
||||
/// If the database cannot be opened.
|
||||
async fn connect(string: String) -> Result<Connection> {
|
||||
Connection::try_from(
|
||||
Database::connect(ConnectOptions::new(string))
|
||||
@@ -14,14 +22,22 @@ async fn connect(string: String) -> Result<Connection> {
|
||||
.context("Connection::try_from")
|
||||
}
|
||||
|
||||
pub async fn open(database_path: String) -> Result<Connection> {
|
||||
connect(format!("sqlite:{database_path}?mode=rw")).await
|
||||
/// Helper for opening an existing database at the given path.
|
||||
///
|
||||
/// # Errors
|
||||
/// If the database cannot be opened.
|
||||
pub(crate) async fn open(path: &Path) -> Result<Connection> {
|
||||
connect(format!("sqlite:{}?mode=rw", path.display())).await
|
||||
}
|
||||
|
||||
pub async fn open_new(database_path: String) -> Result<Connection> {
|
||||
if fs::try_exists(&database_path).await? {
|
||||
/// Helper for creating then opening a database at the given path.
|
||||
///
|
||||
/// # Errors
|
||||
/// If the database already exists or cannot be openend after creation.
|
||||
pub(crate) async fn open_new(path: &Path) -> Result<Connection> {
|
||||
if fs::try_exists(path).await? {
|
||||
bail!("database already exists");
|
||||
}
|
||||
|
||||
connect(format!("sqlite:{database_path}?mode=rwc")).await
|
||||
connect(format!("sqlite:{}?mode=rwc", path.display())).await
|
||||
}
|
||||
|
||||
+29
-27
@@ -1,15 +1,19 @@
|
||||
//! flix-cli
|
||||
//! flix-cli.
|
||||
|
||||
use std::rc::Rc;
|
||||
extern crate alloc;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use alloc::sync::Arc;
|
||||
|
||||
use flix::tmdb::{self, CachePolicy, Client, RedbCache};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use anyhow::{Context as _, Result};
|
||||
use clap::Parser as _;
|
||||
use tokio::fs;
|
||||
|
||||
mod cli;
|
||||
use cli::{AddCommand, Cli, Command, UpdateCommand};
|
||||
use cli::{AddCommand, Cli, Command};
|
||||
|
||||
mod config;
|
||||
use config::Config;
|
||||
@@ -20,47 +24,57 @@ mod db;
|
||||
mod run;
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
#[cfg_attr(test, expect(clippy::missing_errors_doc, reason = "main function"))]
|
||||
#[cfg_attr(test, expect(clippy::missing_panics_doc, reason = "main function "))]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let config = fs::read_to_string(cli.config_path())
|
||||
.await
|
||||
.with_context(|| format!("could not read config: {:?}", cli.config_path()))?;
|
||||
.with_context(|| format!("could not read config: {}", cli.config_path().display()))?;
|
||||
let config: Config = toml::from_str(config.as_str())
|
||||
.with_context(|| format!("could not parse config: {:?}", cli.config_path()))?;
|
||||
.with_context(|| format!("could not parse config: {}", cli.config_path().display()))?;
|
||||
|
||||
let database_path = cli.database_path()?;
|
||||
let database_path = Path::new(&database_path);
|
||||
|
||||
let config = tmdb::Config::new(config.tmdb().bearer_token().to_owned());
|
||||
let cache = Rc::new(RedbCache::new(cli.cache_path())?);
|
||||
let cache = Arc::new(RedbCache::new(cli.cache_path())?);
|
||||
let client = Client::new(config, cache, CachePolicy::Full);
|
||||
|
||||
if cli.trace {
|
||||
if cli.trace() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.with_test_writer()
|
||||
.init();
|
||||
}
|
||||
|
||||
match cli.command() {
|
||||
match cli.get_command() {
|
||||
Command::Init => exec_init(database_path).await?,
|
||||
Command::Add { command, overrides } => {
|
||||
exec_add(client, database_path, command, overrides).await?
|
||||
exec_add(client, database_path, command, overrides).await?;
|
||||
}
|
||||
Command::Update { command } => exec_update(client, database_path, command).await?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exec_init(database_path: String) -> Result<()> {
|
||||
db::open_new(database_path).await?;
|
||||
/// Execute the `init` command.
|
||||
///
|
||||
/// # Errors
|
||||
/// Forwards any errors.
|
||||
async fn exec_init(database_path: &Path) -> Result<()> {
|
||||
drop(db::open_new(database_path).await?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute the `add` command.
|
||||
///
|
||||
/// # Errors
|
||||
/// Forwards any errors.
|
||||
async fn exec_add(
|
||||
client: Client,
|
||||
database_path: String,
|
||||
database_path: &Path,
|
||||
command: AddCommand,
|
||||
overrides: AddOverrides,
|
||||
) -> Result<()> {
|
||||
@@ -77,15 +91,3 @@ async fn exec_add(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exec_update(client: Client, database_path: String, command: UpdateCommand) -> Result<()> {
|
||||
let database = db::open(database_path).await?;
|
||||
|
||||
match command {
|
||||
UpdateCommand::Tmdb { command } => {
|
||||
run::tmdb::update(client, database.as_ref(), command).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+24
-13
@@ -1,3 +1,5 @@
|
||||
//! The `flix` runtime backend.
|
||||
|
||||
use flix::db::entity;
|
||||
use flix::model::id::{CollectionId, ShowId};
|
||||
use flix::model::numbers::{EpisodeNumber, SeasonNumber};
|
||||
@@ -5,12 +7,19 @@ use flix::model::text;
|
||||
|
||||
use anyhow::Result;
|
||||
use sea_orm::ActiveValue::{NotSet, Set};
|
||||
use sea_orm::{ActiveModelTrait, DatabaseConnection, DbErr, TransactionError, TransactionTrait};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait as _, DatabaseConnection, DbErr, TransactionError, TransactionTrait as _,
|
||||
};
|
||||
|
||||
use crate::cli::AddOverrides;
|
||||
use crate::cli::flix::AddCommand;
|
||||
|
||||
pub async fn add(
|
||||
/// Execute an `add` command.
|
||||
///
|
||||
/// # Errors
|
||||
/// Forwards any database transaction failures.
|
||||
#[expect(clippy::print_stdout, reason = "we want to print in a binary")]
|
||||
pub(crate) async fn add(
|
||||
db: &DatabaseConnection,
|
||||
command: AddCommand,
|
||||
overrides: AddOverrides,
|
||||
@@ -50,15 +59,16 @@ pub async fn add(
|
||||
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
Err(TransactionError::Connection(err)) => Err(err)?,
|
||||
Err(TransactionError::Transaction(err)) => Err(err)?,
|
||||
};
|
||||
println!("Created Collection: {}", title);
|
||||
Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
println!("Created Collection: {title}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
AddCommand::Episode {
|
||||
show_slug,
|
||||
show_web_slug,
|
||||
season_number,
|
||||
episode_number,
|
||||
title,
|
||||
@@ -70,11 +80,11 @@ pub async fn add(
|
||||
let title = overrides.title.unwrap_or_else(|| title.clone());
|
||||
|
||||
Box::pin(async move {
|
||||
let show = entity::info::shows::Entity::find_by_web_slug(&show_slug)
|
||||
let show = entity::info::shows::Entity::find_by_web_slug(&show_web_slug)
|
||||
.one(txn)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
DbErr::Custom(format!("show '{}' does not exist", show_slug))
|
||||
DbErr::Custom(format!("show '{show_web_slug}' does not exist"))
|
||||
})?;
|
||||
|
||||
let flix = entity::info::episodes::ActiveModel {
|
||||
@@ -95,10 +105,11 @@ pub async fn add(
|
||||
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
Err(TransactionError::Connection(err)) => Err(err)?,
|
||||
Err(TransactionError::Transaction(err)) => Err(err)?,
|
||||
};
|
||||
println!("Created Episode: {}", title);
|
||||
Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
println!("Created Episode: {title}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod flix;
|
||||
pub mod tmdb;
|
||||
//! Various runtime backends.
|
||||
|
||||
pub(crate) mod flix;
|
||||
pub(crate) mod tmdb;
|
||||
|
||||
+142
-673
@@ -1,4 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
//! The `flix` runtime backend.
|
||||
|
||||
use alloc::collections::BTreeMap;
|
||||
|
||||
use flix::db::entity;
|
||||
use flix::model::id::{CollectionId, MovieId, ShowId};
|
||||
@@ -9,17 +11,24 @@ use flix::tmdb::model::id::{
|
||||
CollectionId as TmdbCollectionId, MovieId as TmdbMovieId, ShowId as TmdbShowId,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use chrono::{Datelike, Utc};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use chrono::{Datelike as _, Utc};
|
||||
use sea_orm::ActiveValue::{NotSet, Set};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, DatabaseConnection, DbErr, EntityTrait, TransactionError, TransactionTrait,
|
||||
ActiveModelTrait as _, DatabaseConnection, DbErr, EntityTrait as _, TransactionError,
|
||||
TransactionTrait as _,
|
||||
};
|
||||
|
||||
use crate::cli::AddOverrides;
|
||||
use crate::cli::tmdb::Command;
|
||||
|
||||
pub async fn add(
|
||||
/// Execute an `add` command.
|
||||
///
|
||||
/// # Errors
|
||||
/// Forwards any database transaction failures or TMDB API failures.
|
||||
#[expect(clippy::print_stderr, reason = "we want to print in a binary")]
|
||||
#[expect(clippy::print_stdout, reason = "we want to print in a binary")]
|
||||
pub(crate) async fn add(
|
||||
client: Client,
|
||||
db: &DatabaseConnection,
|
||||
command: Command,
|
||||
@@ -54,13 +63,15 @@ pub async fn add(
|
||||
.web_slug
|
||||
.unwrap_or_else(|| text::make_web_slug(&title));
|
||||
|
||||
const COLLECTION_SUFFIX_TO_REMOVE: &str = "-collection";
|
||||
if web_slug.ends_with(COLLECTION_SUFFIX_TO_REMOVE) {
|
||||
web_slug.truncate(
|
||||
web_slug
|
||||
.len()
|
||||
.saturating_sub(COLLECTION_SUFFIX_TO_REMOVE.len()),
|
||||
);
|
||||
{
|
||||
const COLLECTION_SUFFIX_TO_REMOVE: &str = "-collection";
|
||||
if web_slug.ends_with(COLLECTION_SUFFIX_TO_REMOVE) {
|
||||
web_slug.truncate(
|
||||
web_slug
|
||||
.len()
|
||||
.saturating_sub(COLLECTION_SUFFIX_TO_REMOVE.len()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let result: Result<CollectionId, TransactionError<DbErr>> = db
|
||||
@@ -78,7 +89,7 @@ pub async fn add(
|
||||
.insert(txn)
|
||||
.await?;
|
||||
|
||||
entity::tmdb::collections::ActiveModel {
|
||||
_ = entity::tmdb::collections::ActiveModel {
|
||||
tmdb_id: Set(id),
|
||||
flix_id: Set(flix.id),
|
||||
last_update: Set(Utc::now()),
|
||||
@@ -94,10 +105,11 @@ pub async fn add(
|
||||
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
Err(TransactionError::Connection(err)) => Err(err)?,
|
||||
Err(TransactionError::Transaction(err)) => Err(err)?,
|
||||
};
|
||||
println!("Created Collection: {}", title);
|
||||
Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
println!("Created Collection: {title}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -145,7 +157,7 @@ pub async fn add(
|
||||
.insert(txn)
|
||||
.await?;
|
||||
|
||||
entity::tmdb::movies::ActiveModel {
|
||||
_ = entity::tmdb::movies::ActiveModel {
|
||||
tmdb_id: Set(id),
|
||||
flix_id: Set(flix.id),
|
||||
last_update: Set(Utc::now()),
|
||||
@@ -162,10 +174,11 @@ pub async fn add(
|
||||
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
Err(TransactionError::Connection(err)) => Err(err)?,
|
||||
Err(TransactionError::Transaction(err)) => Err(err)?,
|
||||
};
|
||||
println!("Created Movie: {} ({})", title, year);
|
||||
Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
println!("Created Movie: {title} ({year})");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -183,7 +196,7 @@ pub async fn add(
|
||||
.await
|
||||
.with_context(|| format!("shows().get_details({})", id.into_raw()))?;
|
||||
let mut seasons = Vec::new();
|
||||
let mut episodes = HashMap::new();
|
||||
let mut episodes = BTreeMap::new();
|
||||
|
||||
for season in 1..=show.number_of_seasons {
|
||||
let season = SeasonNumber::new(season);
|
||||
@@ -191,12 +204,11 @@ pub async fn add(
|
||||
.seasons()
|
||||
.get_details(id, season, None)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("seasons().get_details({}, {})", id.into_raw(), season)
|
||||
}) {
|
||||
.with_context(|| format!("seasons().get_details({}, {season})", id.into_raw()))
|
||||
{
|
||||
Ok(season) => season,
|
||||
Err(err) => {
|
||||
eprintln!("{err:?}");
|
||||
eprintln!("{err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -227,11 +239,9 @@ pub async fn add(
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"skipping episode ({}, {}, {}) - {}",
|
||||
"skipping episode ({}, {}, {episode}) - {err}",
|
||||
id.into_raw(),
|
||||
season.season_number,
|
||||
episode,
|
||||
err
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -239,7 +249,7 @@ pub async fn add(
|
||||
season_episodes.push(episode);
|
||||
}
|
||||
|
||||
episodes.insert(season.season_number, season_episodes);
|
||||
drop(episodes.insert(season.season_number, season_episodes));
|
||||
seasons.push(season);
|
||||
}
|
||||
|
||||
@@ -273,7 +283,7 @@ pub async fn add(
|
||||
.insert(txn)
|
||||
.await?;
|
||||
|
||||
entity::tmdb::shows::ActiveModel {
|
||||
_ = entity::tmdb::shows::ActiveModel {
|
||||
tmdb_id: Set(id),
|
||||
flix_id: Set(flix.id),
|
||||
last_update: Set(Utc::now()),
|
||||
@@ -283,17 +293,19 @@ pub async fn add(
|
||||
.await?;
|
||||
|
||||
for season in seasons {
|
||||
entity::info::seasons::ActiveModel {
|
||||
show_id: Set(flix.id),
|
||||
season_number: Set(season.season_number),
|
||||
title: Set(season.title),
|
||||
overview: Set(season.overview),
|
||||
date: Set(season.air_date),
|
||||
}
|
||||
.insert(txn)
|
||||
.await?;
|
||||
drop(
|
||||
entity::info::seasons::ActiveModel {
|
||||
show_id: Set(flix.id),
|
||||
season_number: Set(season.season_number),
|
||||
title: Set(season.title),
|
||||
overview: Set(season.overview),
|
||||
date: Set(season.air_date),
|
||||
}
|
||||
.insert(txn)
|
||||
.await?,
|
||||
);
|
||||
|
||||
entity::tmdb::seasons::ActiveModel {
|
||||
_ = entity::tmdb::seasons::ActiveModel {
|
||||
tmdb_show: Set(id),
|
||||
tmdb_season: Set(season.season_number),
|
||||
flix_show: Set(flix.id),
|
||||
@@ -306,18 +318,20 @@ pub async fn add(
|
||||
|
||||
for (season, episodes) in episodes {
|
||||
for episode in episodes {
|
||||
entity::info::episodes::ActiveModel {
|
||||
show_id: Set(flix.id),
|
||||
season_number: Set(season),
|
||||
episode_number: Set(episode.episode_number),
|
||||
title: Set(episode.title),
|
||||
overview: Set(episode.overview),
|
||||
date: Set(episode.air_date),
|
||||
}
|
||||
.insert(txn)
|
||||
.await?;
|
||||
drop(
|
||||
entity::info::episodes::ActiveModel {
|
||||
show_id: Set(flix.id),
|
||||
season_number: Set(season),
|
||||
episode_number: Set(episode.episode_number),
|
||||
title: Set(episode.title),
|
||||
overview: Set(episode.overview),
|
||||
date: Set(episode.air_date),
|
||||
}
|
||||
.insert(txn)
|
||||
.await?,
|
||||
);
|
||||
|
||||
entity::tmdb::episodes::ActiveModel {
|
||||
_ = entity::tmdb::episodes::ActiveModel {
|
||||
tmdb_show: Set(id),
|
||||
tmdb_season: Set(season),
|
||||
tmdb_episode: Set(episode.episode_number),
|
||||
@@ -339,10 +353,11 @@ pub async fn add(
|
||||
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
Err(TransactionError::Connection(err)) => Err(err)?,
|
||||
Err(TransactionError::Transaction(err)) => Err(err)?,
|
||||
};
|
||||
println!("Created Show: {} ({})", title, year);
|
||||
Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
println!("Created Show: {title} ({year})");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -366,11 +381,7 @@ pub async fn add(
|
||||
.get_details(id, season_number, None)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"seasons().get_details({}, {})",
|
||||
id.into_raw(),
|
||||
season_number
|
||||
)
|
||||
format!("seasons().get_details({}, {season_number})", id.into_raw())
|
||||
})?;
|
||||
let mut episodes = Vec::new();
|
||||
|
||||
@@ -391,11 +402,9 @@ pub async fn add(
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"skipping episode ({}, {}, {}) - {}",
|
||||
"skipping episode ({}, {}, {episode}) - {err}",
|
||||
id.into_raw(),
|
||||
season.season_number,
|
||||
episode,
|
||||
err
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -406,17 +415,19 @@ pub async fn add(
|
||||
let result: Result<(), TransactionError<DbErr>> = db
|
||||
.transaction(|txn| {
|
||||
Box::pin(async move {
|
||||
entity::info::seasons::ActiveModel {
|
||||
show_id: Set(show.flix_id),
|
||||
season_number: Set(season_number),
|
||||
title: Set(season.title),
|
||||
overview: Set(season.overview),
|
||||
date: Set(season.air_date),
|
||||
}
|
||||
.insert(txn)
|
||||
.await?;
|
||||
drop(
|
||||
entity::info::seasons::ActiveModel {
|
||||
show_id: Set(show.flix_id),
|
||||
season_number: Set(season_number),
|
||||
title: Set(season.title),
|
||||
overview: Set(season.overview),
|
||||
date: Set(season.air_date),
|
||||
}
|
||||
.insert(txn)
|
||||
.await?,
|
||||
);
|
||||
|
||||
entity::tmdb::seasons::ActiveModel {
|
||||
_ = entity::tmdb::seasons::ActiveModel {
|
||||
tmdb_show: Set(show.tmdb_id),
|
||||
tmdb_season: Set(season_number),
|
||||
flix_show: Set(show.flix_id),
|
||||
@@ -427,18 +438,20 @@ pub async fn add(
|
||||
.await?;
|
||||
|
||||
for episode in episodes {
|
||||
entity::info::episodes::ActiveModel {
|
||||
show_id: Set(show.flix_id),
|
||||
season_number: Set(season_number),
|
||||
episode_number: Set(episode.episode_number),
|
||||
title: Set(episode.title),
|
||||
overview: Set(episode.overview),
|
||||
date: Set(episode.air_date),
|
||||
}
|
||||
.insert(txn)
|
||||
.await?;
|
||||
drop(
|
||||
entity::info::episodes::ActiveModel {
|
||||
show_id: Set(show.flix_id),
|
||||
season_number: Set(season_number),
|
||||
episode_number: Set(episode.episode_number),
|
||||
title: Set(episode.title),
|
||||
overview: Set(episode.overview),
|
||||
date: Set(episode.air_date),
|
||||
}
|
||||
.insert(txn)
|
||||
.await?,
|
||||
);
|
||||
|
||||
entity::tmdb::episodes::ActiveModel {
|
||||
_ = entity::tmdb::episodes::ActiveModel {
|
||||
tmdb_show: Set(show.tmdb_id),
|
||||
tmdb_season: Set(season_number),
|
||||
tmdb_episode: Set(episode.episode_number),
|
||||
@@ -458,14 +471,14 @@ pub async fn add(
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
Err(TransactionError::Connection(err)) => Err(err)?,
|
||||
Err(TransactionError::Transaction(err)) => Err(err)?,
|
||||
};
|
||||
Ok(()) => {}
|
||||
Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"Created Season: {} S{}",
|
||||
"Created Season: {} S{season_number}",
|
||||
show.flix_id.into_raw(),
|
||||
season_number
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -476,19 +489,10 @@ pub async fn add(
|
||||
episode,
|
||||
episodes,
|
||||
} => {
|
||||
let id = TmdbShowId::from_raw(id);
|
||||
let season_number = season;
|
||||
|
||||
let Some(show) = entity::tmdb::shows::Entity::find_by_id(id).one(db).await? else {
|
||||
bail!("show does not exists");
|
||||
};
|
||||
let Some(_) = entity::tmdb::seasons::Entity::find_by_id((id, season))
|
||||
.one(db)
|
||||
.await?
|
||||
else {
|
||||
bail!("season does not exists");
|
||||
};
|
||||
|
||||
/// Fetch and store episode information.
|
||||
///
|
||||
/// # Errors
|
||||
/// Forwards any database transaction failures or TMDB API failures.
|
||||
async fn fetch_episode(
|
||||
client: &Client,
|
||||
db: &DatabaseConnection,
|
||||
@@ -512,24 +516,26 @@ pub async fn add(
|
||||
.get_details(id, season, episode_number, None)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("episodes().get_details({}, {})", id.into_raw(), season)
|
||||
format!("episodes().get_details({}, {season})", id.into_raw())
|
||||
})?;
|
||||
|
||||
let result: Result<(), TransactionError<DbErr>> = db
|
||||
.transaction(|txn| {
|
||||
Box::pin(async move {
|
||||
entity::info::episodes::ActiveModel {
|
||||
show_id: Set(flix_id),
|
||||
season_number: Set(season),
|
||||
episode_number: Set(episode_number),
|
||||
title: Set(episode.title),
|
||||
overview: Set(episode.overview),
|
||||
date: Set(episode.air_date),
|
||||
}
|
||||
.insert(txn)
|
||||
.await?;
|
||||
drop(
|
||||
entity::info::episodes::ActiveModel {
|
||||
show_id: Set(flix_id),
|
||||
season_number: Set(season),
|
||||
episode_number: Set(episode_number),
|
||||
title: Set(episode.title),
|
||||
overview: Set(episode.overview),
|
||||
date: Set(episode.air_date),
|
||||
}
|
||||
.insert(txn)
|
||||
.await?,
|
||||
);
|
||||
|
||||
entity::tmdb::episodes::ActiveModel {
|
||||
_ = entity::tmdb::episodes::ActiveModel {
|
||||
tmdb_show: Set(tmdb_id),
|
||||
tmdb_season: Set(season),
|
||||
tmdb_episode: Set(episode_number),
|
||||
@@ -548,20 +554,32 @@ pub async fn add(
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
Err(TransactionError::Connection(err)) => Err(err)?,
|
||||
Err(TransactionError::Transaction(err)) => Err(err)?,
|
||||
};
|
||||
Ok(()) => {}
|
||||
Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"Created Episode: {} S{}E{}",
|
||||
"Created Episode: {} S{season}E{episode_number}",
|
||||
flix_id.into_raw(),
|
||||
season,
|
||||
episode_number
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let id = TmdbShowId::from_raw(id);
|
||||
let season_number = season;
|
||||
|
||||
let Some(show) = entity::tmdb::shows::Entity::find_by_id(id).one(db).await? else {
|
||||
bail!("show does not exists");
|
||||
};
|
||||
let Some(_) = entity::tmdb::seasons::Entity::find_by_id((id, season))
|
||||
.one(db)
|
||||
.await?
|
||||
else {
|
||||
bail!("season does not exists");
|
||||
};
|
||||
|
||||
let flix_id = show.flix_id;
|
||||
let tmdb_id = show.tmdb_id;
|
||||
|
||||
@@ -574,552 +592,3 @@ pub async fn add(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update(client: Client, db: &DatabaseConnection, command: Command) -> Result<()> {
|
||||
_ = client;
|
||||
_ = db;
|
||||
_ = command;
|
||||
unimplemented!("updates")
|
||||
|
||||
// match command {
|
||||
// Command::Collection { id } => {
|
||||
// let id = TmdbCollectionId::from_raw(id);
|
||||
|
||||
// let collection = entity::tmdb::collections::Entity::find_by_id(id)
|
||||
// .one(db)
|
||||
// .await?;
|
||||
// if collection.is_some() {
|
||||
// bail!("collection already exists");
|
||||
// }
|
||||
|
||||
// let collection = client
|
||||
// .collections()
|
||||
// .get_details(id, None)
|
||||
// .await
|
||||
// .with_context(|| format!("collections().get_details({})", id.into_raw()))?;
|
||||
|
||||
// let title = overrides.title.unwrap_or(collection.title);
|
||||
|
||||
// let sort_title = overrides
|
||||
// .sort_title
|
||||
// .unwrap_or_else(|| text::make_sortable_title(&title));
|
||||
// let fs_slug = overrides
|
||||
// .fs_slug
|
||||
// .unwrap_or_else(|| text::make_fs_slug(&title));
|
||||
// let web_slug = overrides
|
||||
// .web_slug
|
||||
// .unwrap_or_else(|| text::make_web_slug(&title));
|
||||
|
||||
// let result: Result<CollectionId, TransactionError<DbErr>> = db
|
||||
// .transaction(|txn| {
|
||||
// let title = title.clone();
|
||||
// Box::pin(async move {
|
||||
// let flix = entity::info::collections::ActiveModel {
|
||||
// id: NotSet,
|
||||
// title: Set(title),
|
||||
// overview: Set(collection.overview),
|
||||
// sort_title: Set(sort_title),
|
||||
// fs_slug: Set(fs_slug),
|
||||
// web_slug: Set(web_slug),
|
||||
// }
|
||||
// .insert(txn)
|
||||
// .await?;
|
||||
|
||||
// entity::tmdb::collections::ActiveModel {
|
||||
// tmdb_id: Set(id),
|
||||
// flix_id: Set(flix.id),
|
||||
// last_update: Set(Utc::now()),
|
||||
// movie_count: Set(collection.movies.len().try_into().unwrap_or(0)),
|
||||
// }
|
||||
// .insert(txn)
|
||||
// .await?;
|
||||
|
||||
// Ok(flix.id)
|
||||
// })
|
||||
// })
|
||||
// .await;
|
||||
|
||||
// let flix_id = match result {
|
||||
// Ok(id) => id,
|
||||
// Err(TransactionError::Connection(err)) => Err(err)?,
|
||||
// Err(TransactionError::Transaction(err)) => Err(err)?,
|
||||
// };
|
||||
// println!("Created Collection: {}", title, flix_id.into_raw());
|
||||
|
||||
// Ok(())
|
||||
// }
|
||||
// Command::Movie { id } => {
|
||||
// let id = TmdbMovieId::from_raw(id);
|
||||
|
||||
// let movie = entity::tmdb::movies::Entity::find_by_id(id).one(db).await?;
|
||||
// if movie.is_some() {
|
||||
// bail!("movie already exists");
|
||||
// }
|
||||
|
||||
// let movie = client
|
||||
// .movies()
|
||||
// .get_details(id, None)
|
||||
// .await
|
||||
// .with_context(|| format!("movies().get_details({})", id.into_raw()))?;
|
||||
|
||||
// let title = overrides.title.unwrap_or(movie.title);
|
||||
// let year = movie.release_date.year();
|
||||
|
||||
// let sort_title = overrides
|
||||
// .sort_title
|
||||
// .unwrap_or_else(|| text::make_sortable_title(&title));
|
||||
// let fs_slug = overrides
|
||||
// .fs_slug
|
||||
// .unwrap_or_else(|| text::make_fs_slug_year(&title, year));
|
||||
// let web_slug = overrides
|
||||
// .web_slug
|
||||
// .unwrap_or_else(|| text::make_web_slug_year(&title, year));
|
||||
|
||||
// let result: Result<MovieId, TransactionError<DbErr>> = db
|
||||
// .transaction(|txn| {
|
||||
// let title = title.clone();
|
||||
// Box::pin(async move {
|
||||
// let flix = entity::info::movies::ActiveModel {
|
||||
// id: NotSet,
|
||||
// title: Set(title),
|
||||
// tagline: Set(movie.tagline),
|
||||
// overview: Set(movie.overview),
|
||||
// date: Set(movie.release_date),
|
||||
// sort_title: Set(sort_title),
|
||||
// fs_slug: Set(fs_slug),
|
||||
// web_slug: Set(web_slug),
|
||||
// }
|
||||
// .insert(txn)
|
||||
// .await?;
|
||||
|
||||
// entity::tmdb::movies::ActiveModel {
|
||||
// tmdb_id: Set(id),
|
||||
// flix_id: Set(flix.id),
|
||||
// last_update: Set(Utc::now()),
|
||||
// runtime: Set(movie.runtime.into()),
|
||||
// collection_id: Set(movie.collection.map(|c| c.id)),
|
||||
// }
|
||||
// .insert(txn)
|
||||
// .await?;
|
||||
|
||||
// Ok(flix.id)
|
||||
// })
|
||||
// })
|
||||
// .await;
|
||||
|
||||
// let flix_id = match result {
|
||||
// Ok(id) => id,
|
||||
// Err(TransactionError::Connection(err)) => Err(err)?,
|
||||
// Err(TransactionError::Transaction(err)) => Err(err)?,
|
||||
// };
|
||||
// println!(
|
||||
// "Created Movie: {} ({})",
|
||||
// title,
|
||||
// year,
|
||||
// flix_id.into_raw(),
|
||||
// );
|
||||
|
||||
// Ok(())
|
||||
// }
|
||||
// Command::Show { id } => {
|
||||
// let id = TmdbShowId::from_raw(id);
|
||||
|
||||
// let show = entity::tmdb::shows::Entity::find_by_id(id).one(db).await?;
|
||||
// if show.is_some() {
|
||||
// bail!("show already exists");
|
||||
// }
|
||||
|
||||
// let show = client
|
||||
// .shows()
|
||||
// .get_details(id, None)
|
||||
// .await
|
||||
// .with_context(|| format!("shows().get_details({})", id.into_raw()))?;
|
||||
// let mut seasons = Vec::new();
|
||||
// let mut episodes = HashMap::new();
|
||||
|
||||
// for season in 1..=show.number_of_seasons {
|
||||
// let season = SeasonNumber::new(season);
|
||||
// let season = match client
|
||||
// .seasons()
|
||||
// .get_details(id, season, None)
|
||||
// .await
|
||||
// .with_context(|| {
|
||||
// format!("seasons().get_details({}, {})", id.into_raw(), season)
|
||||
// }) {
|
||||
// Ok(season) => season,
|
||||
// Err(err) => {
|
||||
// eprintln!("{err:?}");
|
||||
// continue;
|
||||
// }
|
||||
// };
|
||||
// if season.air_date > Utc::now().naive_utc().date() {
|
||||
// eprintln!(
|
||||
// "skipping season ({}, {})",
|
||||
// id.into_raw(),
|
||||
// season.season_number
|
||||
// );
|
||||
// break;
|
||||
// }
|
||||
|
||||
// let Ok(number_of_episodes) = u32::try_from(season.episodes.len()) else {
|
||||
// bail!(
|
||||
// "could not convert {} to an EpisodeNumber",
|
||||
// season.episodes.len()
|
||||
// )
|
||||
// };
|
||||
|
||||
// let mut season_episodes = Vec::new();
|
||||
// for episode in 1..=number_of_episodes {
|
||||
// let episode = EpisodeNumber::new(episode);
|
||||
// let Ok(episode) = client
|
||||
// .episodes()
|
||||
// .get_details(id, season.season_number, episode, None)
|
||||
// .await
|
||||
// else {
|
||||
// eprintln!(
|
||||
// "skipping episode ({}, {}, {})",
|
||||
// id.into_raw(),
|
||||
// season.season_number,
|
||||
// episode
|
||||
// );
|
||||
// break;
|
||||
// };
|
||||
// season_episodes.push(episode);
|
||||
// }
|
||||
|
||||
// episodes.insert(season.season_number, season_episodes);
|
||||
// seasons.push(season);
|
||||
// }
|
||||
|
||||
// let title = overrides.title.unwrap_or(show.title);
|
||||
// let year = show.first_air_date.year();
|
||||
|
||||
// let sort_title = overrides
|
||||
// .sort_title
|
||||
// .unwrap_or_else(|| text::make_sortable_title(&title));
|
||||
// let fs_slug = overrides
|
||||
// .fs_slug
|
||||
// .unwrap_or_else(|| text::make_fs_slug_year(&title, year));
|
||||
// let web_slug = overrides
|
||||
// .web_slug
|
||||
// .unwrap_or_else(|| text::make_web_slug_year(&title, year));
|
||||
|
||||
// let result: Result<ShowId, TransactionError<DbErr>> = db
|
||||
// .transaction(|txn| {
|
||||
// let title = title.clone();
|
||||
// Box::pin(async move {
|
||||
// let flix = entity::info::shows::ActiveModel {
|
||||
// id: NotSet,
|
||||
// title: Set(title),
|
||||
// tagline: Set(show.tagline),
|
||||
// overview: Set(show.overview),
|
||||
// date: Set(show.first_air_date),
|
||||
// sort_title: Set(sort_title),
|
||||
// fs_slug: Set(fs_slug),
|
||||
// web_slug: Set(web_slug),
|
||||
// }
|
||||
// .insert(txn)
|
||||
// .await?;
|
||||
|
||||
// entity::tmdb::shows::ActiveModel {
|
||||
// tmdb_id: Set(id),
|
||||
// flix_id: Set(flix.id),
|
||||
// last_update: Set(Utc::now()),
|
||||
// number_of_seasons: Set(show.number_of_seasons),
|
||||
// }
|
||||
// .insert(txn)
|
||||
// .await?;
|
||||
|
||||
// for season in seasons {
|
||||
// entity::info::seasons::ActiveModel {
|
||||
// show_id: Set(flix.id),
|
||||
// season_number: Set(season.season_number),
|
||||
// title: Set(season.title),
|
||||
// overview: Set(season.overview),
|
||||
// date: Set(season.air_date),
|
||||
// }
|
||||
// .insert(txn)
|
||||
// .await?;
|
||||
|
||||
// entity::tmdb::seasons::ActiveModel {
|
||||
// tmdb_show: Set(id),
|
||||
// tmdb_season: Set(season.season_number),
|
||||
// flix_show: Set(flix.id),
|
||||
// flix_season: Set(season.season_number),
|
||||
// last_update: Set(Utc::now()),
|
||||
// }
|
||||
// .insert(txn)
|
||||
// .await?;
|
||||
// }
|
||||
|
||||
// for (season, episodes) in episodes {
|
||||
// for episode in episodes {
|
||||
// entity::info::episodes::ActiveModel {
|
||||
// show_id: Set(flix.id),
|
||||
// season_number: Set(season),
|
||||
// episode_number: Set(episode.episode_number),
|
||||
// title: Set(episode.title),
|
||||
// overview: Set(episode.overview),
|
||||
// date: Set(episode.air_date),
|
||||
// }
|
||||
// .insert(txn)
|
||||
// .await?;
|
||||
|
||||
// entity::tmdb::episodes::ActiveModel {
|
||||
// tmdb_show: Set(id),
|
||||
// tmdb_season: Set(season),
|
||||
// tmdb_episode: Set(episode.episode_number),
|
||||
// flix_show: Set(flix.id),
|
||||
// flix_season: Set(season),
|
||||
// flix_episode: Set(episode.episode_number),
|
||||
// last_update: Set(Utc::now()),
|
||||
// runtime: Set(episode.runtime.into()),
|
||||
// }
|
||||
// .insert(txn)
|
||||
// .await?;
|
||||
// }
|
||||
// }
|
||||
|
||||
// Ok(flix.id)
|
||||
// })
|
||||
// })
|
||||
// .await;
|
||||
|
||||
// let flix_id = match result {
|
||||
// Ok(id) => id,
|
||||
// Err(TransactionError::Connection(err)) => Err(err)?,
|
||||
// Err(TransactionError::Transaction(err)) => Err(err)?,
|
||||
// };
|
||||
// println!(
|
||||
// "Created Show: {} ({})",
|
||||
// title,
|
||||
// year,
|
||||
// flix_id.into_raw()
|
||||
// );
|
||||
|
||||
// Ok(())
|
||||
// }
|
||||
// Command::Season { id, season } => {
|
||||
// let id = TmdbShowId::from_raw(id);
|
||||
// let season_number = season;
|
||||
|
||||
// let Some(show) = entity::tmdb::shows::Entity::find_by_id(id).one(db).await? else {
|
||||
// bail!("show does not exists");
|
||||
// };
|
||||
|
||||
// let season = entity::tmdb::seasons::Entity::find_by_id((id, season))
|
||||
// .one(db)
|
||||
// .await?;
|
||||
// if season.is_some() {
|
||||
// bail!("season already exists");
|
||||
// }
|
||||
|
||||
// let season = client
|
||||
// .seasons()
|
||||
// .get_details(id, season_number, None)
|
||||
// .await
|
||||
// .with_context(|| {
|
||||
// format!(
|
||||
// "seasons().get_details({}, {})",
|
||||
// id.into_raw(),
|
||||
// season_number
|
||||
// )
|
||||
// })?;
|
||||
// let mut episodes = Vec::new();
|
||||
|
||||
// let Ok(number_of_episodes) = u32::try_from(season.episodes.len()) else {
|
||||
// bail!(
|
||||
// "could not convert {} to an EpisodeNumber",
|
||||
// season.episodes.len()
|
||||
// )
|
||||
// };
|
||||
|
||||
// for episode in 1..=number_of_episodes {
|
||||
// let episode = EpisodeNumber::new(episode);
|
||||
// let Ok(episode) = client
|
||||
// .episodes()
|
||||
// .get_details(id, season.season_number, episode, None)
|
||||
// .await
|
||||
// else {
|
||||
// eprintln!(
|
||||
// "skipping episode ({}, {}, {})",
|
||||
// id.into_raw(),
|
||||
// season.season_number,
|
||||
// episode
|
||||
// );
|
||||
// break;
|
||||
// };
|
||||
// episodes.push(episode);
|
||||
// }
|
||||
|
||||
// let result: Result<(), TransactionError<DbErr>> = db
|
||||
// .transaction(|txn| {
|
||||
// Box::pin(async move {
|
||||
// entity::info::seasons::ActiveModel {
|
||||
// show_id: Set(show.flix_id),
|
||||
// season_number: Set(season_number),
|
||||
// title: Set(season.title),
|
||||
// overview: Set(season.overview),
|
||||
// date: Set(season.air_date),
|
||||
// }
|
||||
// .insert(txn)
|
||||
// .await?;
|
||||
|
||||
// entity::tmdb::seasons::ActiveModel {
|
||||
// tmdb_show: Set(show.tmdb_id),
|
||||
// tmdb_season: Set(season_number),
|
||||
// flix_show: Set(show.flix_id),
|
||||
// flix_season: Set(season_number),
|
||||
// last_update: Set(Utc::now()),
|
||||
// }
|
||||
// .insert(txn)
|
||||
// .await?;
|
||||
|
||||
// for episode in episodes {
|
||||
// entity::info::episodes::ActiveModel {
|
||||
// show_id: Set(show.flix_id),
|
||||
// season_number: Set(season_number),
|
||||
// episode_number: Set(episode.episode_number),
|
||||
// title: Set(episode.title),
|
||||
// overview: Set(episode.overview),
|
||||
// date: Set(episode.air_date),
|
||||
// }
|
||||
// .insert(txn)
|
||||
// .await?;
|
||||
|
||||
// entity::tmdb::episodes::ActiveModel {
|
||||
// tmdb_show: Set(show.tmdb_id),
|
||||
// tmdb_season: Set(season_number),
|
||||
// tmdb_episode: Set(episode.episode_number),
|
||||
// flix_show: Set(show.flix_id),
|
||||
// flix_season: Set(season_number),
|
||||
// flix_episode: Set(episode.episode_number),
|
||||
// last_update: Set(Utc::now()),
|
||||
// runtime: Set(episode.runtime.into()),
|
||||
// }
|
||||
// .insert(txn)
|
||||
// .await?;
|
||||
// }
|
||||
|
||||
// Ok(())
|
||||
// })
|
||||
// })
|
||||
// .await;
|
||||
|
||||
// match result {
|
||||
// Ok(_) => (),
|
||||
// Err(TransactionError::Connection(err)) => Err(err)?,
|
||||
// Err(TransactionError::Transaction(err)) => Err(err)?,
|
||||
// };
|
||||
// println!(
|
||||
// "Created Season: {} S{}",
|
||||
// show.flix_id.into_raw(),
|
||||
// season_number
|
||||
// );
|
||||
|
||||
// Ok(())
|
||||
// }
|
||||
// Command::Episode {
|
||||
// id,
|
||||
// season,
|
||||
// episode,
|
||||
// episodes,
|
||||
// } => {
|
||||
// let id = TmdbShowId::from_raw(id);
|
||||
// let season_number = season;
|
||||
|
||||
// let Some(show) = entity::tmdb::shows::Entity::find_by_id(id).one(db).await? else {
|
||||
// bail!("show does not exists");
|
||||
// };
|
||||
// let Some(_) = entity::tmdb::seasons::Entity::find_by_id((id, season))
|
||||
// .one(db)
|
||||
// .await?
|
||||
// else {
|
||||
// bail!("season does not exists");
|
||||
// };
|
||||
|
||||
// async fn fetch_episode(
|
||||
// client: &Client,
|
||||
// db: &DatabaseConnection,
|
||||
// flix_id: ShowId,
|
||||
// tmdb_id: TmdbShowId,
|
||||
// id: TmdbShowId,
|
||||
// season: SeasonNumber,
|
||||
// episode: EpisodeNumber,
|
||||
// ) -> Result<()> {
|
||||
// let episode_number = episode;
|
||||
|
||||
// let episode = entity::tmdb::episodes::Entity::find_by_id((id, season, episode))
|
||||
// .one(db)
|
||||
// .await?;
|
||||
// if episode.is_some() {
|
||||
// bail!("episode already exists");
|
||||
// }
|
||||
|
||||
// let episode = client
|
||||
// .episodes()
|
||||
// .get_details(id, season, episode_number, None)
|
||||
// .await
|
||||
// .with_context(|| {
|
||||
// format!("episodes().get_details({}, {})", id.into_raw(), season)
|
||||
// })?;
|
||||
|
||||
// let result: Result<(), TransactionError<DbErr>> = db
|
||||
// .transaction(|txn| {
|
||||
// Box::pin(async move {
|
||||
// entity::info::episodes::ActiveModel {
|
||||
// show_id: Set(flix_id),
|
||||
// season_number: Set(season),
|
||||
// episode_number: Set(episode_number),
|
||||
// title: Set(episode.title),
|
||||
// overview: Set(episode.overview),
|
||||
// date: Set(episode.air_date),
|
||||
// }
|
||||
// .insert(txn)
|
||||
// .await?;
|
||||
|
||||
// entity::tmdb::episodes::ActiveModel {
|
||||
// tmdb_show: Set(tmdb_id),
|
||||
// tmdb_season: Set(season),
|
||||
// tmdb_episode: Set(episode_number),
|
||||
// flix_show: Set(flix_id),
|
||||
// flix_season: Set(season),
|
||||
// flix_episode: Set(episode_number),
|
||||
// last_update: Set(Utc::now()),
|
||||
// runtime: Set(episode.runtime.into()),
|
||||
// }
|
||||
// .insert(txn)
|
||||
// .await?;
|
||||
|
||||
// Ok(())
|
||||
// })
|
||||
// })
|
||||
// .await;
|
||||
|
||||
// match result {
|
||||
// Ok(_) => (),
|
||||
// Err(TransactionError::Connection(err)) => Err(err)?,
|
||||
// Err(TransactionError::Transaction(err)) => Err(err)?,
|
||||
// };
|
||||
// println!(
|
||||
// "Created Episode: {} S{}E{}",
|
||||
// flix_id.into_raw(),
|
||||
// season,
|
||||
// episode_number
|
||||
// );
|
||||
|
||||
// Ok(())
|
||||
// }
|
||||
|
||||
// let flix_id = show.flix_id;
|
||||
// let tmdb_id = show.tmdb_id;
|
||||
|
||||
// fetch_episode(&client, db, flix_id, tmdb_id, id, season_number, episode).await?;
|
||||
// for episode in episodes {
|
||||
// fetch_episode(&client, db, flix_id, tmdb_id, id, season_number, episode).await?;
|
||||
// }
|
||||
|
||||
// Ok(())
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
[package]
|
||||
name = "flix-db"
|
||||
version = "0.0.20"
|
||||
license-file.workspace = true
|
||||
|
||||
version = "0.1.0"
|
||||
description = "Types for storing persistent data about 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
|
||||
@@ -29,7 +31,7 @@ flix-tmdb = { workspace = true, features = ["sea-orm"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
sea-orm-migration = { workspace = true, features = ["runtime-tokio-rustls"] }
|
||||
tokio = { version = "^1", default-features = false, features = [
|
||||
tokio = { workspace = true, features = [
|
||||
"macros",
|
||||
"rt",
|
||||
] }
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
//! Types and functions related to [DatabaseConnection]s
|
||||
//! Types and functions related to [`DatabaseConnection`]s.
|
||||
|
||||
use sea_orm::{DatabaseConnection, DbErr};
|
||||
use sea_orm_migration::MigratorTrait as _;
|
||||
|
||||
/// A newtype wrapping a [DatabaseConnection]
|
||||
/// A newtype wrapping a [`DatabaseConnection`].
|
||||
#[derive(Debug)]
|
||||
pub struct Connection(DatabaseConnection);
|
||||
|
||||
impl Connection {
|
||||
/// Helper function for applying database migrations while wrapping a
|
||||
/// [DatabaseConnection] in a newtype
|
||||
/// [`DatabaseConnection`] in a newtype.
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails if connecting or applying migrations fail.
|
||||
#[inline]
|
||||
pub async fn try_from(db: DatabaseConnection) -> Result<Self, DbErr> {
|
||||
// The migrations only create views which store no data, so it is
|
||||
// important to down before up since modifications are impossible.
|
||||
//
|
||||
// Syncing the schema registry allows all real tables to be upgraded.
|
||||
// It is important to sync twice to ensure internal consistency so that
|
||||
// the views can be recreated on the latest schema.
|
||||
crate::migration::Migrator::down(&db, None).await?;
|
||||
db.get_schema_registry("flix_db::*").sync(&db).await?;
|
||||
db.get_schema_registry("flix_db::*").sync(&db).await?;
|
||||
@@ -19,6 +30,7 @@ impl Connection {
|
||||
}
|
||||
|
||||
impl AsRef<DatabaseConnection> for Connection {
|
||||
#[inline]
|
||||
fn as_ref(&self) -> &DatabaseConnection {
|
||||
&self.0
|
||||
}
|
||||
|
||||
+183
-145
@@ -1,6 +1,6 @@
|
||||
//! This module contains entities for storing media file information
|
||||
//! This module contains entities for storing media file information.
|
||||
|
||||
/// Library entity
|
||||
/// Library entity.
|
||||
pub mod libraries {
|
||||
use flix_model::id::LibraryId;
|
||||
|
||||
@@ -10,42 +10,43 @@ pub mod libraries {
|
||||
use chrono::{DateTime, Utc};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
/// The database representation of a library media folder
|
||||
/// The database representation of a library media folder.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_libraries")]
|
||||
pub struct Model {
|
||||
/// The library's ID
|
||||
/// The library's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: LibraryId,
|
||||
/// The library's directory
|
||||
/// The library's directory.
|
||||
pub directory: PathBytes,
|
||||
/// The library's last scan data
|
||||
/// The library's last scan data.
|
||||
pub last_scan_date: Option<DateTime<Utc>>,
|
||||
/// The library's last scan duration
|
||||
/// The library's last scan duration.
|
||||
pub last_scan_duration: Option<Seconds>,
|
||||
|
||||
/// Collections that are part of this library
|
||||
/// Collections that are part of this library.
|
||||
#[sea_orm(has_many)]
|
||||
pub collections: HasMany<super::collections::Entity>,
|
||||
/// Movies that are part of this library
|
||||
/// Movies that are part of this library.
|
||||
#[sea_orm(has_many)]
|
||||
pub movies: HasMany<super::movies::Entity>,
|
||||
/// Shows that are part of this library
|
||||
/// Shows that are part of this library.
|
||||
#[sea_orm(has_many)]
|
||||
pub shows: HasMany<super::shows::Entity>,
|
||||
/// Seasons that are part of this library
|
||||
/// Seasons that are part of this library.
|
||||
#[sea_orm(has_many)]
|
||||
pub seasons: HasMany<super::seasons::Entity>,
|
||||
/// Episodes that are part of this library
|
||||
/// Episodes that are part of this library.
|
||||
#[sea_orm(has_many)]
|
||||
pub episodes: HasMany<super::episodes::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Collection entity
|
||||
/// Collection entity.
|
||||
pub mod collections {
|
||||
use flix_model::id::{CollectionId, LibraryId};
|
||||
|
||||
@@ -55,25 +56,25 @@ pub mod collections {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a collection media folder
|
||||
/// The database representation of a collection media folder.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_collections")]
|
||||
pub struct Model {
|
||||
/// The collection's ID
|
||||
/// The collection's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: CollectionId,
|
||||
/// The collection's parent
|
||||
/// The collection's parent.
|
||||
#[sea_orm(indexed)]
|
||||
pub parent_id: Option<CollectionId>,
|
||||
/// The collection's library ID
|
||||
/// The collection's library ID.
|
||||
pub library_id: LibraryId,
|
||||
/// The collection's directory
|
||||
/// The collection's directory.
|
||||
pub directory: PathBytes,
|
||||
/// The collection's poster path
|
||||
/// The collection's poster path.
|
||||
pub relative_poster_path: Option<String>,
|
||||
|
||||
/// This collection's parent
|
||||
/// This collection's parent.
|
||||
#[sea_orm(
|
||||
self_ref,
|
||||
relation_enum = "Parent",
|
||||
@@ -83,7 +84,7 @@ pub mod collections {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub parent: HasOne<Entity>,
|
||||
/// The library this collection belongs to
|
||||
/// The library this collection belongs to.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "library_id",
|
||||
@@ -92,7 +93,7 @@ pub mod collections {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub library: HasOne<super::libraries::Entity>,
|
||||
/// The info for this collection
|
||||
/// The info for this collection.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
relation_enum = "Info",
|
||||
@@ -103,15 +104,16 @@ pub mod collections {
|
||||
)]
|
||||
pub info: HasOne<entity::info::collections::Entity>,
|
||||
|
||||
/// The watched info for this collection
|
||||
/// The watched info for this collection.
|
||||
#[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")]
|
||||
pub watched: HasMany<entity::watched::collections::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Movie entity
|
||||
/// Movie entity.
|
||||
pub mod movies {
|
||||
use flix_model::id::{CollectionId, LibraryId, MovieId};
|
||||
|
||||
@@ -121,27 +123,27 @@ pub mod movies {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a movie media folder
|
||||
/// The database representation of a movie media folder.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_movies")]
|
||||
pub struct Model {
|
||||
/// The movie's ID
|
||||
/// The movie's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: MovieId,
|
||||
/// The movie's parent
|
||||
/// The movie's parent.
|
||||
#[sea_orm(indexed)]
|
||||
pub parent_id: Option<CollectionId>,
|
||||
/// The movie's library
|
||||
/// The movie's library.
|
||||
pub library_id: LibraryId,
|
||||
/// The movie's directory
|
||||
/// The movie's directory.
|
||||
pub directory: PathBytes,
|
||||
/// The movie's media path
|
||||
/// The movie's media path.
|
||||
pub relative_media_path: String,
|
||||
/// The movie's poster path
|
||||
/// The movie's poster path.
|
||||
pub relative_poster_path: Option<String>,
|
||||
|
||||
/// This movie's parent
|
||||
/// This movie's parent.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "parent_id",
|
||||
@@ -150,7 +152,7 @@ pub mod movies {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub parent: HasOne<super::collections::Entity>,
|
||||
/// The library this movie belongs to
|
||||
/// The library this movie belongs to.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "library_id",
|
||||
@@ -159,7 +161,7 @@ pub mod movies {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub library: HasOne<super::libraries::Entity>,
|
||||
/// The info for this movie
|
||||
/// The info for this movie.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
relation_enum = "Info",
|
||||
@@ -170,15 +172,16 @@ pub mod movies {
|
||||
)]
|
||||
pub info: HasOne<entity::info::movies::Entity>,
|
||||
|
||||
/// The watched info for this movie
|
||||
/// The watched info for this movie.
|
||||
#[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")]
|
||||
pub watched: HasMany<entity::watched::movies::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Show entity
|
||||
/// Show entity.
|
||||
pub mod shows {
|
||||
use flix_model::id::{CollectionId, LibraryId, ShowId};
|
||||
|
||||
@@ -188,25 +191,25 @@ pub mod shows {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a show media folder
|
||||
/// The database representation of a show media folder.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_shows")]
|
||||
pub struct Model {
|
||||
/// The show's ID
|
||||
/// The show's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: ShowId,
|
||||
/// The show's parent
|
||||
/// The show's parent.
|
||||
#[sea_orm(indexed)]
|
||||
pub parent_id: Option<CollectionId>,
|
||||
/// The show's library
|
||||
/// The show's library.
|
||||
pub library_id: LibraryId,
|
||||
/// The show's directory
|
||||
/// The show's directory.
|
||||
pub directory: PathBytes,
|
||||
/// The show's poster path
|
||||
/// The show's poster path.
|
||||
pub relative_poster_path: Option<String>,
|
||||
|
||||
/// This show's parent
|
||||
/// This show's parent.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "parent_id",
|
||||
@@ -215,7 +218,7 @@ pub mod shows {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub parent: HasOne<super::collections::Entity>,
|
||||
/// The library this show belongs to
|
||||
/// The library this show belongs to.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "library_id",
|
||||
@@ -224,7 +227,7 @@ pub mod shows {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub library: HasOne<super::libraries::Entity>,
|
||||
/// The info for this show
|
||||
/// The info for this show.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
relation_enum = "Info",
|
||||
@@ -235,21 +238,22 @@ pub mod shows {
|
||||
)]
|
||||
pub info: HasOne<entity::info::shows::Entity>,
|
||||
|
||||
/// Seasons that are part of this show
|
||||
/// Seasons that are part of this show.
|
||||
#[sea_orm(has_many)]
|
||||
pub seasons: HasMany<super::seasons::Entity>,
|
||||
/// Episodes that are part of this show
|
||||
/// Episodes that are part of this show.
|
||||
#[sea_orm(has_many)]
|
||||
pub episodes: HasMany<super::episodes::Entity>,
|
||||
/// The watched info for this show
|
||||
/// The watched info for this show.
|
||||
#[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")]
|
||||
pub watched: HasMany<entity::watched::shows::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Season entity
|
||||
/// Season entity.
|
||||
pub mod seasons {
|
||||
use flix_model::id::{LibraryId, ShowId};
|
||||
use flix_model::numbers::SeasonNumber;
|
||||
@@ -260,25 +264,25 @@ pub mod seasons {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a season media folder
|
||||
/// The database representation of a season media folder.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_seasons")]
|
||||
pub struct Model {
|
||||
/// The season's show's ID
|
||||
/// The season's show's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub show_id: ShowId,
|
||||
/// The season's number
|
||||
/// The season's number.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub season_number: SeasonNumber,
|
||||
/// The season's library
|
||||
/// The season's library.
|
||||
pub library_id: LibraryId,
|
||||
/// The season's directory
|
||||
/// The season's directory.
|
||||
pub directory: PathBytes,
|
||||
/// The season's poster path
|
||||
/// The season's poster path.
|
||||
pub relative_poster_path: Option<String>,
|
||||
|
||||
/// This season's show
|
||||
/// This season's show.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "show_id",
|
||||
@@ -287,7 +291,7 @@ pub mod seasons {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub show: HasOne<super::shows::Entity>,
|
||||
/// The library this season belongs to
|
||||
/// The library this season belongs to.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "library_id",
|
||||
@@ -296,7 +300,7 @@ pub mod seasons {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub library: HasOne<super::libraries::Entity>,
|
||||
/// The info for this season
|
||||
/// The info for this season.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
relation_enum = "Info",
|
||||
@@ -307,10 +311,10 @@ pub mod seasons {
|
||||
)]
|
||||
pub info: HasOne<entity::info::seasons::Entity>,
|
||||
|
||||
/// Episodes that are part of this show
|
||||
/// Episodes that are part of this show.
|
||||
#[sea_orm(has_many)]
|
||||
pub episodes: HasMany<super::episodes::Entity>,
|
||||
/// The watched info for this season
|
||||
/// The watched info for this season.
|
||||
#[sea_orm(
|
||||
has_many,
|
||||
relation_enum = "Watched",
|
||||
@@ -320,10 +324,11 @@ pub mod seasons {
|
||||
pub watched: HasMany<entity::watched::seasons::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Episode entity
|
||||
/// Episode entity.
|
||||
pub mod episodes {
|
||||
use flix_model::id::{LibraryId, ShowId};
|
||||
use flix_model::numbers::{EpisodeNumber, SeasonNumber};
|
||||
@@ -334,32 +339,32 @@ pub mod episodes {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a episode media folder
|
||||
/// The database representation of a episode media folder.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_episodes")]
|
||||
pub struct Model {
|
||||
/// The episode's show's ID
|
||||
/// The episode's show's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub show_id: ShowId,
|
||||
/// The episode's season's number
|
||||
/// The episode's season's number.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub season_number: SeasonNumber,
|
||||
/// The episode's number
|
||||
/// The episode's number.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub episode_number: EpisodeNumber,
|
||||
/// The number of additional contained episodes
|
||||
/// The number of additional contained episodes.
|
||||
pub count: u8,
|
||||
/// The episode's library
|
||||
/// The episode's library.
|
||||
pub library_id: LibraryId,
|
||||
/// The episode's directory
|
||||
/// The episode's directory.
|
||||
pub directory: PathBytes,
|
||||
/// The episode's media path
|
||||
/// The episode's media path.
|
||||
pub relative_media_path: String,
|
||||
/// The episode's poster path
|
||||
/// The episode's poster path.
|
||||
pub relative_poster_path: Option<String>,
|
||||
|
||||
/// This episode's show
|
||||
/// This episode's show.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "show_id",
|
||||
@@ -368,7 +373,7 @@ pub mod episodes {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub show: HasOne<super::shows::Entity>,
|
||||
/// This episode's season
|
||||
/// This episode's season.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "(show_id, season_number)",
|
||||
@@ -377,7 +382,7 @@ pub mod episodes {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub season: HasOne<super::seasons::Entity>,
|
||||
/// The library this episode belongs to
|
||||
/// The library this episode belongs to.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "library_id",
|
||||
@@ -386,7 +391,7 @@ pub mod episodes {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub library: HasOne<super::libraries::Entity>,
|
||||
/// The info for this episode
|
||||
/// The info for this episode.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
relation_enum = "Info",
|
||||
@@ -397,7 +402,7 @@ pub mod episodes {
|
||||
)]
|
||||
pub info: HasOne<entity::info::episodes::Entity>,
|
||||
|
||||
/// The watched info for this episode
|
||||
/// The watched info for this episode.
|
||||
#[sea_orm(
|
||||
has_many,
|
||||
relation_enum = "Watched",
|
||||
@@ -407,23 +412,26 @@ pub mod episodes {
|
||||
pub watched: HasMany<entity::watched::episodes::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Macros for creating content entities
|
||||
/// Macros for creating content entities.
|
||||
#[cfg(test)]
|
||||
pub mod test {
|
||||
macro_rules! make_content_library {
|
||||
($db:expr, $id:expr) => {
|
||||
$crate::entity::content::libraries::ActiveModel {
|
||||
id: Set(::flix_model::id::LibraryId::from_raw($id)),
|
||||
directory: Set(::std::path::PathBuf::new().into()),
|
||||
last_scan_date: Set(None),
|
||||
last_scan_duration: Set(None),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert");
|
||||
drop(
|
||||
$crate::entity::content::libraries::ActiveModel {
|
||||
id: Set(::flix_model::id::LibraryId::from_raw($id)),
|
||||
directory: Set(::std::path::PathBuf::new().into()),
|
||||
last_scan_date: Set(None),
|
||||
last_scan_duration: Set(None),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert"),
|
||||
);
|
||||
};
|
||||
}
|
||||
pub(crate) use make_content_library;
|
||||
@@ -431,16 +439,18 @@ pub mod test {
|
||||
macro_rules! make_content_collection {
|
||||
($db:expr, $lid:expr, $id:expr, $pid:expr) => {
|
||||
$crate::entity::info::test::make_info_collection!($db, $id);
|
||||
$crate::entity::content::collections::ActiveModel {
|
||||
id: Set(::flix_model::id::CollectionId::from_raw($id)),
|
||||
parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
|
||||
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
|
||||
directory: Set(::std::path::PathBuf::new().into()),
|
||||
relative_poster_path: Set(::core::option::Option::None),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert");
|
||||
drop(
|
||||
$crate::entity::content::collections::ActiveModel {
|
||||
id: Set(::flix_model::id::CollectionId::from_raw($id)),
|
||||
parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
|
||||
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
|
||||
directory: Set(::std::path::PathBuf::new().into()),
|
||||
relative_poster_path: Set(::core::option::Option::None),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert"),
|
||||
);
|
||||
};
|
||||
}
|
||||
pub(crate) use make_content_collection;
|
||||
@@ -448,17 +458,19 @@ pub mod test {
|
||||
macro_rules! make_content_movie {
|
||||
($db:expr, $lid:expr, $id:expr, $pid:expr) => {
|
||||
$crate::entity::info::test::make_info_movie!($db, $id);
|
||||
$crate::entity::content::movies::ActiveModel {
|
||||
id: Set(::flix_model::id::MovieId::from_raw($id)),
|
||||
parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
|
||||
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
|
||||
directory: Set(::std::path::PathBuf::new().into()),
|
||||
relative_media_path: Set(::std::string::String::new()),
|
||||
relative_poster_path: Set(::core::option::Option::None),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert");
|
||||
drop(
|
||||
$crate::entity::content::movies::ActiveModel {
|
||||
id: Set(::flix_model::id::MovieId::from_raw($id)),
|
||||
parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
|
||||
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
|
||||
directory: Set(::std::path::PathBuf::new().into()),
|
||||
relative_media_path: Set(::alloc::string::String::new()),
|
||||
relative_poster_path: Set(::core::option::Option::None),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert"),
|
||||
);
|
||||
};
|
||||
}
|
||||
pub(crate) use make_content_movie;
|
||||
@@ -466,16 +478,18 @@ pub mod test {
|
||||
macro_rules! make_content_show {
|
||||
($db:expr, $lid:expr, $id:expr, $pid:expr) => {
|
||||
$crate::entity::info::test::make_info_show!($db, $id);
|
||||
$crate::entity::content::shows::ActiveModel {
|
||||
id: Set(::flix_model::id::ShowId::from_raw($id)),
|
||||
parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
|
||||
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
|
||||
directory: Set(::std::path::PathBuf::new().into()),
|
||||
relative_poster_path: Set(::core::option::Option::None),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert");
|
||||
drop(
|
||||
$crate::entity::content::shows::ActiveModel {
|
||||
id: Set(::flix_model::id::ShowId::from_raw($id)),
|
||||
parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
|
||||
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
|
||||
directory: Set(::std::path::PathBuf::new().into()),
|
||||
relative_poster_path: Set(::core::option::Option::None),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert"),
|
||||
);
|
||||
};
|
||||
}
|
||||
pub(crate) use make_content_show;
|
||||
@@ -483,16 +497,18 @@ pub mod test {
|
||||
macro_rules! make_content_season {
|
||||
($db:expr, $lid:expr, $show:expr, $season:expr) => {
|
||||
$crate::entity::info::test::make_info_season!($db, $show, $season);
|
||||
$crate::entity::content::seasons::ActiveModel {
|
||||
show_id: Set(::flix_model::id::ShowId::from_raw($show)),
|
||||
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
|
||||
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
|
||||
directory: Set(::std::path::PathBuf::new().into()),
|
||||
relative_poster_path: Set(::core::option::Option::None),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert");
|
||||
drop(
|
||||
$crate::entity::content::seasons::ActiveModel {
|
||||
show_id: Set(::flix_model::id::ShowId::from_raw($show)),
|
||||
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
|
||||
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
|
||||
directory: Set(::std::path::PathBuf::new().into()),
|
||||
relative_poster_path: Set(::core::option::Option::None),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert"),
|
||||
);
|
||||
};
|
||||
}
|
||||
pub(crate) use make_content_season;
|
||||
@@ -506,19 +522,21 @@ pub mod test {
|
||||
};
|
||||
(@make, $db:expr, $lid:expr, $show:expr, $season:expr, $episode:expr, $count:literal) => {
|
||||
$crate::entity::info::test::make_info_episode!($db, $show, $season, $episode);
|
||||
$crate::entity::content::episodes::ActiveModel {
|
||||
show_id: Set(::flix_model::id::ShowId::from_raw($show)),
|
||||
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
|
||||
episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
|
||||
count: Set($count),
|
||||
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
|
||||
directory: Set(::std::path::PathBuf::new().into()),
|
||||
relative_media_path: Set(::std::string::String::new()),
|
||||
relative_poster_path: Set(::core::option::Option::None),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert");
|
||||
drop(
|
||||
$crate::entity::content::episodes::ActiveModel {
|
||||
show_id: Set(::flix_model::id::ShowId::from_raw($show)),
|
||||
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
|
||||
episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
|
||||
count: Set($count),
|
||||
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
|
||||
directory: Set(::std::path::PathBuf::new().into()),
|
||||
relative_media_path: Set(::alloc::string::String::new()),
|
||||
relative_poster_path: Set(::core::option::Option::None),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert"),
|
||||
);
|
||||
};
|
||||
}
|
||||
pub(crate) use make_content_episode;
|
||||
@@ -550,7 +568,10 @@ mod tests {
|
||||
use super::super::tests::get_error_kind;
|
||||
use super::super::tests::{noneable, notsettable};
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn use_test_macros() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
@@ -562,8 +583,10 @@ mod tests {
|
||||
make_content_episode!(&db, 1, 1, 1, 1);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_libraries() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn round_trip_libraries() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_library {
|
||||
@@ -601,8 +624,11 @@ mod tests {
|
||||
assert_library!(&db, 6, Success; last_scan_duration);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_collections() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn round_trip_collections() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_collection {
|
||||
@@ -656,8 +682,11 @@ mod tests {
|
||||
assert_collection!(&db, 7, None, 1, Success; relative_poster_path);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_movies() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn round_trip_movies() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_movie {
|
||||
@@ -718,8 +747,11 @@ mod tests {
|
||||
assert_movie!(&db, 8, None, 1, Success; relative_poster_path);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_shows() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn round_trip_shows() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_show {
|
||||
@@ -776,8 +808,11 @@ mod tests {
|
||||
assert_show!(&db, 7, None, 1, Success; relative_poster_path);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_seasons() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn round_trip_seasons() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_season {
|
||||
@@ -828,8 +863,11 @@ mod tests {
|
||||
assert_season!(&db, 1, 7, 1, Success; relative_poster_path);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_episodes() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn round_trip_episodes() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_episode {
|
||||
|
||||
+149
-119
@@ -1,7 +1,7 @@
|
||||
//! This module contains entities for storing media information such as
|
||||
//! titles and overviews
|
||||
//! titles and overviews.
|
||||
|
||||
/// Collection entity
|
||||
/// Collection entity.
|
||||
pub mod collections {
|
||||
use flix_model::id::CollectionId;
|
||||
|
||||
@@ -9,38 +9,39 @@ pub mod collections {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a flix collection
|
||||
/// The database representation of a flix collection.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_info_collections")]
|
||||
pub struct Model {
|
||||
/// The collection's ID
|
||||
/// The collection's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: CollectionId,
|
||||
/// The collection's title
|
||||
/// The collection's title.
|
||||
pub title: String,
|
||||
/// The collection's overview
|
||||
/// The collection's overview.
|
||||
pub overview: String,
|
||||
|
||||
/// The sortable title
|
||||
/// The sortable title.
|
||||
#[sea_orm(indexed)]
|
||||
pub sort_title: String,
|
||||
/// The filesystem-safe slug
|
||||
/// The filesystem-safe slug.
|
||||
#[sea_orm(indexed, unique)]
|
||||
pub fs_slug: String,
|
||||
/// The url-safe slug
|
||||
/// The url-safe slug.
|
||||
#[sea_orm(indexed, unique)]
|
||||
pub web_slug: String,
|
||||
|
||||
/// Potential content for this collection
|
||||
/// Potential content for this collection.
|
||||
#[sea_orm(has_one)]
|
||||
pub content: HasOne<entity::content::collections::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Movie entity
|
||||
/// Movie entity.
|
||||
pub mod movies {
|
||||
use flix_model::id::MovieId;
|
||||
|
||||
@@ -49,43 +50,44 @@ pub mod movies {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a flix movie
|
||||
/// The database representation of a flix movie.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_info_movies")]
|
||||
pub struct Model {
|
||||
/// The movie's ID
|
||||
/// The movie's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: MovieId,
|
||||
/// The movie's title
|
||||
/// The movie's title.
|
||||
pub title: String,
|
||||
/// The movie's tagline
|
||||
/// The movie's tagline.
|
||||
pub tagline: String,
|
||||
/// The movie's overview
|
||||
/// The movie's overview.
|
||||
pub overview: String,
|
||||
/// The movie's release date
|
||||
/// The movie's release date.
|
||||
#[sea_orm(indexed)]
|
||||
pub date: NaiveDate,
|
||||
|
||||
/// The sortable title
|
||||
/// The sortable title.
|
||||
#[sea_orm(indexed)]
|
||||
pub sort_title: String,
|
||||
/// The filesystem-safe slug
|
||||
/// The filesystem-safe slug.
|
||||
#[sea_orm(indexed, unique)]
|
||||
pub fs_slug: String,
|
||||
/// The url-safe slug
|
||||
/// The url-safe slug.
|
||||
#[sea_orm(indexed, unique)]
|
||||
pub web_slug: String,
|
||||
|
||||
/// Potential content for this movie
|
||||
/// Potential content for this movie.
|
||||
#[sea_orm(has_one)]
|
||||
pub content: HasOne<entity::content::movies::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Show entity
|
||||
/// Show entity.
|
||||
pub mod shows {
|
||||
use flix_model::id::ShowId;
|
||||
|
||||
@@ -94,50 +96,51 @@ pub mod shows {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a flix show
|
||||
/// The database representation of a flix show.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_info_shows")]
|
||||
pub struct Model {
|
||||
/// The show's ID
|
||||
/// The show's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: ShowId,
|
||||
/// The show's title
|
||||
/// The show's title.
|
||||
pub title: String,
|
||||
/// The show's tagline
|
||||
/// The show's tagline.
|
||||
pub tagline: String,
|
||||
/// The show's overview
|
||||
/// The show's overview.
|
||||
pub overview: String,
|
||||
/// The show's air date
|
||||
/// The show's air date.
|
||||
#[sea_orm(indexed)]
|
||||
pub date: NaiveDate,
|
||||
|
||||
/// The sortable title
|
||||
/// The sortable title.
|
||||
#[sea_orm(indexed)]
|
||||
pub sort_title: String,
|
||||
/// The filesystem-safe slug
|
||||
/// The filesystem-safe slug.
|
||||
#[sea_orm(indexed, unique)]
|
||||
pub fs_slug: String,
|
||||
/// The url-safe slug
|
||||
/// The url-safe slug.
|
||||
#[sea_orm(indexed, unique)]
|
||||
pub web_slug: String,
|
||||
|
||||
/// Seasons that are part of this show
|
||||
/// Seasons that are part of this show.
|
||||
#[sea_orm(has_many)]
|
||||
pub seasons: HasMany<super::seasons::Entity>,
|
||||
/// Episodes that are part of this show
|
||||
/// Episodes that are part of this show.
|
||||
#[sea_orm(has_many)]
|
||||
pub episodes: HasMany<super::episodes::Entity>,
|
||||
|
||||
/// Potential content for this show
|
||||
/// Potential content for this show.
|
||||
#[sea_orm(has_one)]
|
||||
pub content: HasOne<entity::content::shows::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Season entity
|
||||
/// Season entity.
|
||||
pub mod seasons {
|
||||
use flix_model::id::ShowId;
|
||||
use flix_model::numbers::SeasonNumber;
|
||||
@@ -147,26 +150,26 @@ pub mod seasons {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a flix season
|
||||
/// The database representation of a flix season.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_info_seasons")]
|
||||
pub struct Model {
|
||||
/// The season's show's ID
|
||||
/// The season's show's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub show_id: ShowId,
|
||||
/// The season's number
|
||||
/// The season's number.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub season_number: SeasonNumber,
|
||||
/// The season's title
|
||||
/// The season's title.
|
||||
pub title: String,
|
||||
/// The season's overview
|
||||
/// The season's overview.
|
||||
pub overview: String,
|
||||
/// The season's air date
|
||||
/// The season's air date.
|
||||
#[sea_orm(indexed)]
|
||||
pub date: NaiveDate,
|
||||
|
||||
/// The show this season belongs to
|
||||
/// The show this season belongs to.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "show_id",
|
||||
@@ -175,19 +178,20 @@ pub mod seasons {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub show: HasOne<super::shows::Entity>,
|
||||
/// Episodes that are part of this season
|
||||
/// Episodes that are part of this season.
|
||||
#[sea_orm(has_many)]
|
||||
pub episodes: HasMany<super::episodes::Entity>,
|
||||
|
||||
/// Potential content for this season
|
||||
/// Potential content for this season.
|
||||
#[sea_orm(has_one)]
|
||||
pub content: HasOne<entity::content::seasons::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Episode entity
|
||||
/// Episode entity.
|
||||
pub mod episodes {
|
||||
use flix_model::id::ShowId;
|
||||
use flix_model::numbers::{EpisodeNumber, SeasonNumber};
|
||||
@@ -197,29 +201,29 @@ pub mod episodes {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a flix episode
|
||||
/// The database representation of a flix episode.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_info_episodes")]
|
||||
pub struct Model {
|
||||
/// The episode's show's ID
|
||||
/// The episode's show's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub show_id: ShowId,
|
||||
/// The episode's season's number
|
||||
/// The episode's season's number.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub season_number: SeasonNumber,
|
||||
/// The episode's number
|
||||
/// The episode's number.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub episode_number: EpisodeNumber,
|
||||
/// The episode's title
|
||||
/// The episode's title.
|
||||
pub title: String,
|
||||
/// The episode's overview
|
||||
/// The episode's overview.
|
||||
pub overview: String,
|
||||
/// The episode's air date
|
||||
/// The episode's air date.
|
||||
#[sea_orm(indexed)]
|
||||
pub date: NaiveDate,
|
||||
|
||||
/// The show this episode belongs to
|
||||
/// The show this episode belongs to.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "show_id",
|
||||
@@ -228,7 +232,7 @@ pub mod episodes {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub show: HasOne<super::shows::Entity>,
|
||||
/// The season this episode belongs to
|
||||
/// The season this episode belongs to.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "(show_id, season_number)",
|
||||
@@ -238,101 +242,112 @@ pub mod episodes {
|
||||
)]
|
||||
pub season: HasOne<super::seasons::Entity>,
|
||||
|
||||
/// Potential content for this episode
|
||||
/// Potential content for this episode.
|
||||
#[sea_orm(has_one)]
|
||||
pub content: HasOne<entity::content::episodes::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Macros for creating info entities
|
||||
/// Macros for creating info entities.
|
||||
#[cfg(test)]
|
||||
pub mod test {
|
||||
macro_rules! make_info_collection {
|
||||
($db:expr, $id:expr) => {
|
||||
$crate::entity::info::collections::ActiveModel {
|
||||
id: Set(::flix_model::id::CollectionId::from_raw($id)),
|
||||
title: Set(::std::string::String::new()),
|
||||
overview: Set(::std::string::String::new()),
|
||||
sort_title: Set(::std::string::String::new()),
|
||||
fs_slug: Set(format!("C FS {}", $id)),
|
||||
web_slug: Set(format!("C Web {}", $id)),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert");
|
||||
drop(
|
||||
$crate::entity::info::collections::ActiveModel {
|
||||
id: Set(::flix_model::id::CollectionId::from_raw($id)),
|
||||
title: Set(::alloc::string::String::new()),
|
||||
overview: Set(::alloc::string::String::new()),
|
||||
sort_title: Set(::alloc::string::String::new()),
|
||||
fs_slug: Set(format!("C FS {}", $id)),
|
||||
web_slug: Set(format!("C Web {}", $id)),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert"),
|
||||
);
|
||||
};
|
||||
}
|
||||
pub(crate) use make_info_collection;
|
||||
|
||||
macro_rules! make_info_movie {
|
||||
($db:expr, $id:expr) => {
|
||||
$crate::entity::info::movies::ActiveModel {
|
||||
id: Set(::flix_model::id::MovieId::from_raw($id)),
|
||||
title: Set(::std::string::String::new()),
|
||||
tagline: Set(::std::string::String::new()),
|
||||
overview: Set(::std::string::String::new()),
|
||||
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
|
||||
sort_title: Set(::std::string::String::new()),
|
||||
fs_slug: Set(format!("M FS {}", $id)),
|
||||
web_slug: Set(format!("M Web {}", $id)),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert");
|
||||
drop(
|
||||
$crate::entity::info::movies::ActiveModel {
|
||||
id: Set(::flix_model::id::MovieId::from_raw($id)),
|
||||
title: Set(::alloc::string::String::new()),
|
||||
tagline: Set(::alloc::string::String::new()),
|
||||
overview: Set(::alloc::string::String::new()),
|
||||
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
|
||||
sort_title: Set(::alloc::string::String::new()),
|
||||
fs_slug: Set(format!("M FS {}", $id)),
|
||||
web_slug: Set(format!("M Web {}", $id)),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert"),
|
||||
);
|
||||
};
|
||||
}
|
||||
pub(crate) use make_info_movie;
|
||||
|
||||
macro_rules! make_info_show {
|
||||
($db:expr, $id:expr) => {
|
||||
$crate::entity::info::shows::ActiveModel {
|
||||
id: Set(::flix_model::id::ShowId::from_raw($id)),
|
||||
title: Set(::std::string::String::new()),
|
||||
tagline: Set(::std::string::String::new()),
|
||||
overview: Set(::std::string::String::new()),
|
||||
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
|
||||
sort_title: Set(::std::string::String::new()),
|
||||
fs_slug: Set(format!("S FS {}", $id)),
|
||||
web_slug: Set(format!("S Web {}", $id)),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert");
|
||||
drop(
|
||||
$crate::entity::info::shows::ActiveModel {
|
||||
id: Set(::flix_model::id::ShowId::from_raw($id)),
|
||||
title: Set(::alloc::string::String::new()),
|
||||
tagline: Set(::alloc::string::String::new()),
|
||||
overview: Set(::alloc::string::String::new()),
|
||||
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
|
||||
sort_title: Set(::alloc::string::String::new()),
|
||||
fs_slug: Set(format!("S FS {}", $id)),
|
||||
web_slug: Set(format!("S Web {}", $id)),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert"),
|
||||
);
|
||||
};
|
||||
}
|
||||
pub(crate) use make_info_show;
|
||||
|
||||
macro_rules! make_info_season {
|
||||
($db:expr, $show:expr, $season:expr) => {
|
||||
$crate::entity::info::seasons::ActiveModel {
|
||||
show_id: Set(::flix_model::id::ShowId::from_raw($show)),
|
||||
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
|
||||
title: Set(::std::string::String::new()),
|
||||
overview: Set(::std::string::String::new()),
|
||||
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert");
|
||||
drop(
|
||||
$crate::entity::info::seasons::ActiveModel {
|
||||
show_id: Set(::flix_model::id::ShowId::from_raw($show)),
|
||||
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
|
||||
title: Set(::alloc::string::String::new()),
|
||||
overview: Set(::alloc::string::String::new()),
|
||||
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert"),
|
||||
);
|
||||
};
|
||||
}
|
||||
pub(crate) use make_info_season;
|
||||
|
||||
macro_rules! make_info_episode {
|
||||
($db:expr, $show:expr, $season:expr, $episode:expr) => {
|
||||
$crate::entity::info::episodes::ActiveModel {
|
||||
show_id: Set(::flix_model::id::ShowId::from_raw($show)),
|
||||
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
|
||||
episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
|
||||
title: Set(::std::string::String::new()),
|
||||
overview: Set(::std::string::String::new()),
|
||||
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert");
|
||||
drop(
|
||||
$crate::entity::info::episodes::ActiveModel {
|
||||
show_id: Set(::flix_model::id::ShowId::from_raw($show)),
|
||||
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
|
||||
episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
|
||||
title: Set(::alloc::string::String::new()),
|
||||
overview: Set(::alloc::string::String::new()),
|
||||
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
|
||||
}
|
||||
.insert($db)
|
||||
.await
|
||||
.expect("insert"),
|
||||
);
|
||||
};
|
||||
}
|
||||
pub(crate) use make_info_episode;
|
||||
@@ -355,7 +370,10 @@ mod tests {
|
||||
make_info_collection, make_info_episode, make_info_movie, make_info_season, make_info_show,
|
||||
};
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn use_test_macros() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
@@ -366,8 +384,10 @@ mod tests {
|
||||
make_info_episode!(&db, 1, 1, 1);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_collections() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn round_trip_collections() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_collection {
|
||||
@@ -409,8 +429,10 @@ mod tests {
|
||||
assert_collection!(&db, 8, NotNullViolation; web_slug);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_movies() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn round_trip_movies() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_movie {
|
||||
@@ -458,8 +480,10 @@ mod tests {
|
||||
assert_movie!(&db, 10, NotNullViolation; web_slug);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_shows() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn round_trip_shows() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_show {
|
||||
@@ -510,8 +534,11 @@ mod tests {
|
||||
assert_show!(&db, 10, NotNullViolation; web_slug);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_seasons() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn round_trip_seasons() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_season {
|
||||
@@ -561,8 +588,11 @@ mod tests {
|
||||
assert_season!(&db, 1, 7, NotNullViolation; date);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_episodes() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn round_trip_episodes() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_episode {
|
||||
|
||||
+113
-63
@@ -1,4 +1,10 @@
|
||||
//! Entity structs for interacting with the database
|
||||
//! Entity structs for interacting with the database.
|
||||
|
||||
#![expect(clippy::exhaustive_enums, reason = "reflects the database schema")]
|
||||
#![expect(clippy::exhaustive_structs, reason = "reflects the database schema")]
|
||||
#![expect(clippy::derive_partial_eq_without_eq, reason = "#[sea_orm::model]")]
|
||||
#![expect(clippy::impl_trait_in_params, reason = "#[sea_orm::model]")]
|
||||
#![expect(clippy::same_name_method, reason = "#[sea_orm::model]")]
|
||||
|
||||
pub mod content;
|
||||
pub mod info;
|
||||
@@ -13,8 +19,6 @@ mod tests {
|
||||
use flix_tmdb::model::id::TmdbRepr as TmdbReprId;
|
||||
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use sea_orm::DbErr;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::sqlx::error::ErrorKind;
|
||||
use seamantic::model::id::SeaOrmRepr as SeaOrmReprId;
|
||||
@@ -32,35 +36,36 @@ mod tests {
|
||||
use crate::tests::new_initialized_memory_db;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ErrorKindError {
|
||||
NotRuntimeError,
|
||||
NotSqlxError,
|
||||
NotDatabaseError,
|
||||
pub(crate) enum ErrorKindNot {
|
||||
Runtime,
|
||||
Sqlx,
|
||||
Database,
|
||||
}
|
||||
|
||||
pub fn get_error_kind(error: DbErr) -> Result<ErrorKind, ErrorKindError> {
|
||||
let runtime_err = match error {
|
||||
DbErr::Conn(runtime_err) => runtime_err,
|
||||
DbErr::Exec(runtime_err) => runtime_err,
|
||||
DbErr::Query(runtime_err) => runtime_err,
|
||||
_ => return Err(ErrorKindError::NotRuntimeError),
|
||||
/// Extract the error kind from a [`DbErr`].
|
||||
///
|
||||
/// # Errors
|
||||
/// If the [`DbErr`] internals diverge from [`SqlxError::Database`].
|
||||
pub(crate) fn get_error_kind(error: DbErr) -> Result<ErrorKind, ErrorKindNot> {
|
||||
let (DbErr::Conn(runtime_err) | DbErr::Exec(runtime_err) | DbErr::Query(runtime_err)) =
|
||||
error
|
||||
else {
|
||||
return Err(ErrorKindNot::Runtime);
|
||||
};
|
||||
|
||||
let sqlx_err = match runtime_err {
|
||||
sea_orm::RuntimeErr::SqlxError(sqlx_err) => sqlx_err,
|
||||
_ => return Err(ErrorKindError::NotSqlxError),
|
||||
let RuntimeErr::SqlxError(sqlx_err) = runtime_err else {
|
||||
return Err(ErrorKindNot::Sqlx);
|
||||
};
|
||||
|
||||
let database_err = match sqlx_err.as_ref() {
|
||||
sea_orm::SqlxError::Database(database_err) => database_err,
|
||||
_ => return Err(ErrorKindError::NotDatabaseError),
|
||||
let SqlxError::Database(database_err) = sqlx_err.as_ref() else {
|
||||
return Err(ErrorKindNot::Database);
|
||||
};
|
||||
|
||||
Ok(database_err.kind())
|
||||
}
|
||||
|
||||
/// Helper macro for writing tests for `ActiveModel` structs where
|
||||
/// toggling [sea_orm::ActiveValue] is needed
|
||||
/// toggling [`sea_orm::ActiveValue`] is needed.
|
||||
macro_rules! notsettable {
|
||||
($field:ident, $value:expr $(, $($skip:ident),+)?) => {
|
||||
if notsettable!(@skip, $field $(, $($skip),+)?) {
|
||||
@@ -76,7 +81,7 @@ mod tests {
|
||||
pub(super) use notsettable;
|
||||
|
||||
/// Helper macro for writing tests for `ActiveModel` structs where
|
||||
/// toggling [sea_orm::ActiveValue] is needed
|
||||
/// toggling [`sea_orm::ActiveValue`] is needed.
|
||||
macro_rules! noneable {
|
||||
($field:ident, $value:expr $(, $($skip:ident),+)?) => {
|
||||
if noneable!(@skip, $field $(, $($skip),+)?) {
|
||||
@@ -148,6 +153,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize a database with data to be deleted.
|
||||
///
|
||||
/// # Panics
|
||||
/// If any database operation fails, since this is only for testing.
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn initialize_deletion_test_database(id: &DbId) -> DatabaseConnection {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
@@ -284,12 +294,14 @@ mod tests {
|
||||
db
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_delete_info_collection() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_info_collection() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::info::collections::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
|
||||
_ = entity::info::collections::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
|
||||
id.flix.collection,
|
||||
))
|
||||
.exec(&db)
|
||||
@@ -368,12 +380,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_delete_info_movie() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_info_movie() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::info::movies::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
|
||||
_ = entity::info::movies::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
|
||||
id.flix.movie,
|
||||
))
|
||||
.exec(&db)
|
||||
@@ -452,15 +466,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_delete_info_show() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_info_show() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::info::shows::Entity::delete_by_id(seamantic::model::id::Id::from_raw(id.flix.show))
|
||||
.exec(&db)
|
||||
.await
|
||||
.expect("Entity::delete_by_id");
|
||||
_ = entity::info::shows::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
|
||||
id.flix.show,
|
||||
))
|
||||
.exec(&db)
|
||||
.await
|
||||
.expect("Entity::delete_by_id");
|
||||
|
||||
assert_eq!(
|
||||
Ok(1),
|
||||
@@ -534,12 +552,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_delete_info_season() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_info_season() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::info::seasons::Entity::delete_by_id((
|
||||
_ = entity::info::seasons::Entity::delete_by_id((
|
||||
seamantic::model::id::Id::from_raw(id.flix.show),
|
||||
SeasonNumber::new(id.flix.season),
|
||||
))
|
||||
@@ -619,12 +639,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_delete_info_episodes() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_info_episodes() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::info::episodes::Entity::delete_by_id((
|
||||
_ = entity::info::episodes::Entity::delete_by_id((
|
||||
seamantic::model::id::Id::from_raw(id.flix.show),
|
||||
SeasonNumber::new(id.flix.season),
|
||||
EpisodeNumber::new(id.flix.episode),
|
||||
@@ -705,13 +727,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[cfg(feature = "tmdb")]
|
||||
#[tokio::test]
|
||||
async fn test_delete_tmdb_collection() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_tmdb_collection() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::tmdb::collections::Entity::delete_by_id(flix_tmdb::model::id::Id::from_raw(
|
||||
_ = entity::tmdb::collections::Entity::delete_by_id(flix_tmdb::model::id::Id::from_raw(
|
||||
id.tmdb.collection,
|
||||
))
|
||||
.exec(&db)
|
||||
@@ -790,13 +814,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[cfg(feature = "tmdb")]
|
||||
#[tokio::test]
|
||||
async fn test_delete_tmdb_movie() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_tmdb_movie() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::tmdb::movies::Entity::delete_by_id(flix_tmdb::model::id::Id::from_raw(
|
||||
_ = entity::tmdb::movies::Entity::delete_by_id(flix_tmdb::model::id::Id::from_raw(
|
||||
id.tmdb.movie,
|
||||
))
|
||||
.exec(&db)
|
||||
@@ -875,16 +901,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[cfg(feature = "tmdb")]
|
||||
#[tokio::test]
|
||||
async fn test_delete_tmdb_show() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_tmdb_show() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::tmdb::shows::Entity::delete_by_id(flix_tmdb::model::id::Id::from_raw(id.tmdb.show))
|
||||
.exec(&db)
|
||||
.await
|
||||
.expect("Entity::delete_by_id");
|
||||
_ = entity::tmdb::shows::Entity::delete_by_id(flix_tmdb::model::id::Id::from_raw(
|
||||
id.tmdb.show,
|
||||
))
|
||||
.exec(&db)
|
||||
.await
|
||||
.expect("Entity::delete_by_id");
|
||||
|
||||
assert_eq!(
|
||||
Ok(1),
|
||||
@@ -958,13 +988,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[cfg(feature = "tmdb")]
|
||||
#[tokio::test]
|
||||
async fn test_delete_tmdb_season() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_tmdb_season() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::tmdb::seasons::Entity::delete_by_id((
|
||||
_ = entity::tmdb::seasons::Entity::delete_by_id((
|
||||
flix_tmdb::model::id::Id::from_raw(id.tmdb.show),
|
||||
SeasonNumber::new(id.tmdb.season),
|
||||
))
|
||||
@@ -1044,13 +1076,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[cfg(feature = "tmdb")]
|
||||
#[tokio::test]
|
||||
async fn test_delete_tmdb_episode() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_tmdb_episode() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::tmdb::episodes::Entity::delete_by_id((
|
||||
_ = entity::tmdb::episodes::Entity::delete_by_id((
|
||||
flix_tmdb::model::id::Id::from_raw(id.tmdb.show),
|
||||
SeasonNumber::new(id.tmdb.season),
|
||||
EpisodeNumber::new(id.tmdb.episode),
|
||||
@@ -1131,12 +1165,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_delete_content_library() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_content_library() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::content::libraries::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
|
||||
_ = entity::content::libraries::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
|
||||
id.content.library,
|
||||
))
|
||||
.exec(&db)
|
||||
@@ -1215,12 +1251,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_delete_content_collection() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_content_collection() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::content::collections::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
|
||||
_ = entity::content::collections::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
|
||||
id.flix.collection,
|
||||
))
|
||||
.exec(&db)
|
||||
@@ -1299,12 +1337,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_delete_content_movie() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_content_movie() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::content::movies::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
|
||||
_ = entity::content::movies::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
|
||||
id.flix.movie,
|
||||
))
|
||||
.exec(&db)
|
||||
@@ -1383,12 +1423,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_delete_content_show() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_content_show() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::content::shows::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
|
||||
_ = entity::content::shows::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
|
||||
id.flix.show,
|
||||
))
|
||||
.exec(&db)
|
||||
@@ -1467,12 +1509,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_delete_content_season() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_content_season() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::content::seasons::Entity::delete_by_id((
|
||||
_ = entity::content::seasons::Entity::delete_by_id((
|
||||
seamantic::model::id::Id::from_raw(id.flix.show),
|
||||
SeasonNumber::new(id.flix.season),
|
||||
))
|
||||
@@ -1552,12 +1596,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_delete_content_episode() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_content_episode() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::content::episodes::Entity::delete_by_id((
|
||||
_ = entity::content::episodes::Entity::delete_by_id((
|
||||
seamantic::model::id::Id::from_raw(id.flix.show),
|
||||
SeasonNumber::new(id.flix.season),
|
||||
EpisodeNumber::new(id.flix.episode),
|
||||
@@ -1638,12 +1684,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_delete_watched_movie() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_watched_movie() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::watched::movies::Entity::delete_by_id((
|
||||
_ = entity::watched::movies::Entity::delete_by_id((
|
||||
seamantic::model::id::Id::from_raw(id.flix.movie),
|
||||
id.watch.user,
|
||||
))
|
||||
@@ -1723,12 +1771,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_delete_watched_episode() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
async fn delete_watched_episode() {
|
||||
let id = DbId::default();
|
||||
let db = initialize_deletion_test_database(&id).await;
|
||||
|
||||
entity::watched::episodes::Entity::delete_by_id((
|
||||
_ = entity::watched::episodes::Entity::delete_by_id((
|
||||
seamantic::model::id::Id::from_raw(id.flix.show),
|
||||
SeasonNumber::new(id.flix.season),
|
||||
EpisodeNumber::new(id.flix.episode),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! This module contains entities for storing dynamic data from TMDB
|
||||
//! This module contains entities for storing dynamic data from TMDB.
|
||||
|
||||
/// Collection entity
|
||||
/// Collection entity.
|
||||
pub mod collections {
|
||||
use flix_model::id::CollectionId as FlixId;
|
||||
use flix_tmdb::model::id::CollectionId;
|
||||
@@ -10,23 +10,23 @@ pub mod collections {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a tmdb collection
|
||||
/// The database representation of a tmdb collection.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_tmdb_collections")]
|
||||
pub struct Model {
|
||||
/// The collection's TMDB ID
|
||||
/// The collection's TMDB ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub tmdb_id: CollectionId,
|
||||
/// The collection's ID
|
||||
/// The collection's ID.
|
||||
#[sea_orm(unique)]
|
||||
pub flix_id: FlixId,
|
||||
/// The date of the last update
|
||||
/// The date of the last update.
|
||||
pub last_update: DateTime<Utc>,
|
||||
/// The number of movies in the collection
|
||||
/// The number of movies in the collection.
|
||||
pub movie_count: u16,
|
||||
|
||||
/// The info for this collection
|
||||
/// The info for this collection.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "flix_id",
|
||||
@@ -36,15 +36,16 @@ pub mod collections {
|
||||
)]
|
||||
pub info: HasOne<entity::info::collections::Entity>,
|
||||
|
||||
/// Movies that are in this collection
|
||||
/// Movies that are in this collection.
|
||||
#[sea_orm(has_many)]
|
||||
pub movies: HasMany<super::movies::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Movie entity
|
||||
/// Movie entity.
|
||||
pub mod movies {
|
||||
use flix_model::id::MovieId as FlixId;
|
||||
use flix_tmdb::model::id::{CollectionId, MovieId};
|
||||
@@ -56,26 +57,26 @@ pub mod movies {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a tmdb movie
|
||||
/// The database representation of a tmdb movie.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_tmdb_movies")]
|
||||
pub struct Model {
|
||||
/// The movie's TMDB ID
|
||||
/// The movie's TMDB ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub tmdb_id: MovieId,
|
||||
/// The movie's ID
|
||||
/// The movie's ID.
|
||||
#[sea_orm(unique)]
|
||||
pub flix_id: FlixId,
|
||||
/// The date of the last update
|
||||
/// The date of the last update.
|
||||
pub last_update: DateTime<Utc>,
|
||||
/// The movie's runtime in seconds
|
||||
/// The movie's runtime in seconds.
|
||||
pub runtime: Seconds,
|
||||
/// The TMDB ID of the collection this movie belongs to
|
||||
/// The TMDB ID of the collection this movie belongs to.
|
||||
#[sea_orm(indexed)]
|
||||
pub collection_id: Option<CollectionId>,
|
||||
|
||||
/// The collection this movie belongs to
|
||||
/// The collection this movie belongs to.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "collection_id",
|
||||
@@ -84,7 +85,7 @@ pub mod movies {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub collection: HasOne<super::collections::Entity>,
|
||||
/// The info for this movie
|
||||
/// The info for this movie.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "flix_id",
|
||||
@@ -95,10 +96,11 @@ pub mod movies {
|
||||
pub info: HasOne<entity::info::movies::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Show entity
|
||||
/// Show entity.
|
||||
pub mod shows {
|
||||
use flix_model::id::ShowId as FlixId;
|
||||
use flix_tmdb::model::id::ShowId;
|
||||
@@ -108,23 +110,23 @@ pub mod shows {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a tmdb show
|
||||
/// The database representation of a tmdb show.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_tmdb_shows")]
|
||||
pub struct Model {
|
||||
/// The show's TMDB ID
|
||||
/// The show's TMDB ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub tmdb_id: ShowId,
|
||||
/// The show's ID
|
||||
/// The show's ID.
|
||||
#[sea_orm(unique)]
|
||||
pub flix_id: FlixId,
|
||||
/// The movie's runtime in seconds
|
||||
/// The movie's runtime in seconds.
|
||||
pub last_update: DateTime<Utc>,
|
||||
/// The number of seasons the show has
|
||||
/// The number of seasons the show has.
|
||||
pub number_of_seasons: u32,
|
||||
|
||||
/// The info for this show
|
||||
/// The info for this show.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "flix_id",
|
||||
@@ -134,18 +136,19 @@ pub mod shows {
|
||||
)]
|
||||
pub info: HasOne<entity::info::shows::Entity>,
|
||||
|
||||
/// Seasons that are part of this show
|
||||
/// Seasons that are part of this show.
|
||||
#[sea_orm(has_many)]
|
||||
pub seasons: HasMany<super::seasons::Entity>,
|
||||
/// Episodes that are part of this show
|
||||
/// Episodes that are part of this show.
|
||||
#[sea_orm(has_many)]
|
||||
pub episodes: HasMany<super::episodes::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Season entity
|
||||
/// Season entity.
|
||||
pub mod seasons {
|
||||
use flix_model::id::ShowId as FlixId;
|
||||
use flix_model::numbers::SeasonNumber;
|
||||
@@ -156,27 +159,27 @@ pub mod seasons {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a tmdb season
|
||||
/// The database representation of a tmdb season.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_tmdb_seasons")]
|
||||
pub struct Model {
|
||||
/// The season's show's TMDB ID
|
||||
/// The season's show's TMDB ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub tmdb_show: ShowId,
|
||||
/// The season's TMDB season number
|
||||
/// The season's TMDB season number.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub tmdb_season: SeasonNumber,
|
||||
/// The season's show's ID
|
||||
/// The season's show's ID.
|
||||
#[sea_orm(unique_key = "flix")]
|
||||
pub flix_show: FlixId,
|
||||
/// The season's number
|
||||
/// The season's number.
|
||||
#[sea_orm(unique_key = "flix")]
|
||||
pub flix_season: SeasonNumber,
|
||||
/// The date of the last update
|
||||
/// The date of the last update.
|
||||
pub last_update: DateTime<Utc>,
|
||||
|
||||
/// The show this season belongs to
|
||||
/// The show this season belongs to.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "tmdb_show",
|
||||
@@ -185,7 +188,7 @@ pub mod seasons {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub show: HasOne<super::shows::Entity>,
|
||||
/// The info for this season
|
||||
/// The info for this season.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "(flix_show, flix_season)",
|
||||
@@ -195,15 +198,16 @@ pub mod seasons {
|
||||
)]
|
||||
pub info: HasOne<entity::info::seasons::Entity>,
|
||||
|
||||
/// Episodes that are part of this season
|
||||
/// Episodes that are part of this season.
|
||||
#[sea_orm(has_many)]
|
||||
pub episodes: HasMany<super::episodes::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Season entity
|
||||
/// Season entity.
|
||||
pub mod episodes {
|
||||
use flix_model::id::ShowId as FlixId;
|
||||
use flix_model::numbers::{EpisodeNumber, SeasonNumber};
|
||||
@@ -215,35 +219,35 @@ pub mod episodes {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a tmdb episode
|
||||
/// The database representation of a tmdb episode.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_tmdb_episodes")]
|
||||
pub struct Model {
|
||||
/// The episode's show's TMDB ID
|
||||
/// The episode's show's TMDB ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub tmdb_show: ShowId,
|
||||
/// The episode's season's TMDB season number
|
||||
/// The episode's season's TMDB season number.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub tmdb_season: SeasonNumber,
|
||||
/// The episode's TMDB episode number
|
||||
/// The episode's TMDB episode number.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub tmdb_episode: EpisodeNumber,
|
||||
/// The episode's show's ID
|
||||
/// The episode's show's ID.
|
||||
#[sea_orm(unique_key = "flix")]
|
||||
pub flix_show: FlixId,
|
||||
/// The episode's season's number
|
||||
/// The episode's season's number.
|
||||
#[sea_orm(unique_key = "flix")]
|
||||
pub flix_season: SeasonNumber,
|
||||
/// The episode's number
|
||||
/// The episode's number.
|
||||
#[sea_orm(unique_key = "flix")]
|
||||
pub flix_episode: EpisodeNumber,
|
||||
/// The date of the last update
|
||||
/// The date of the last update.
|
||||
pub last_update: DateTime<Utc>,
|
||||
/// The episode's runtime in seconds
|
||||
/// The episode's runtime in seconds.
|
||||
pub runtime: Seconds,
|
||||
|
||||
/// The show this episode belongs to
|
||||
/// The show this episode belongs to.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "tmdb_show",
|
||||
@@ -252,7 +256,7 @@ pub mod episodes {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub show: HasOne<super::shows::Entity>,
|
||||
/// The season this episode belongs to
|
||||
/// The season this episode belongs to.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "(tmdb_show, tmdb_season)",
|
||||
@@ -261,7 +265,7 @@ pub mod episodes {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub season: HasOne<super::seasons::Entity>,
|
||||
/// The info for this episode
|
||||
/// The info for this episode.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "(flix_show, flix_season, flix_episode)",
|
||||
@@ -272,15 +276,16 @@ pub mod episodes {
|
||||
pub info: HasOne<entity::info::episodes::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Macros for creating tmdb entities
|
||||
/// Macros for creating tmdb entities.
|
||||
#[cfg(test)]
|
||||
pub mod test {
|
||||
macro_rules! make_tmdb_collection {
|
||||
($db:expr, $id:expr, $flix_id:expr) => {
|
||||
$crate::entity::tmdb::collections::ActiveModel {
|
||||
_ = $crate::entity::tmdb::collections::ActiveModel {
|
||||
tmdb_id: Set(::flix_tmdb::model::id::CollectionId::from_raw($id)),
|
||||
flix_id: Set(::flix_model::id::CollectionId::from_raw($flix_id)),
|
||||
last_update: Set(::chrono::Utc::now()),
|
||||
@@ -295,7 +300,7 @@ pub mod test {
|
||||
|
||||
macro_rules! make_tmdb_movie {
|
||||
($db:expr, $id:expr, $flix_id:expr) => {
|
||||
$crate::entity::tmdb::movies::ActiveModel {
|
||||
_ = $crate::entity::tmdb::movies::ActiveModel {
|
||||
tmdb_id: Set(::flix_tmdb::model::id::MovieId::from_raw($id)),
|
||||
flix_id: Set(::flix_model::id::MovieId::from_raw($flix_id)),
|
||||
last_update: Set(::chrono::Utc::now()),
|
||||
@@ -311,7 +316,7 @@ pub mod test {
|
||||
|
||||
macro_rules! make_tmdb_show {
|
||||
($db:expr, $id:expr, $flix_id:expr) => {
|
||||
$crate::entity::tmdb::shows::ActiveModel {
|
||||
_ = $crate::entity::tmdb::shows::ActiveModel {
|
||||
tmdb_id: Set(::flix_tmdb::model::id::ShowId::from_raw($id)),
|
||||
flix_id: Set(::flix_model::id::ShowId::from_raw($flix_id)),
|
||||
last_update: Set(::chrono::Utc::now()),
|
||||
@@ -326,7 +331,7 @@ pub mod test {
|
||||
|
||||
macro_rules! make_tmdb_season {
|
||||
($db:expr, $show:expr, $season:expr, $flix_show:expr, $flix_season:expr) => {
|
||||
$crate::entity::tmdb::seasons::ActiveModel {
|
||||
_ = $crate::entity::tmdb::seasons::ActiveModel {
|
||||
tmdb_show: Set(::flix_tmdb::model::id::ShowId::from_raw($show)),
|
||||
tmdb_season: Set(::flix_model::numbers::SeasonNumber::new($season)),
|
||||
flix_show: Set(::flix_model::id::ShowId::from_raw($flix_show)),
|
||||
@@ -342,7 +347,7 @@ pub mod test {
|
||||
|
||||
macro_rules! make_tmdb_episode {
|
||||
($db:expr, $show:expr, $season:expr, $episode:expr, $flix_show:expr, $flix_season:expr, $flix_episode:expr) => {
|
||||
$crate::entity::tmdb::episodes::ActiveModel {
|
||||
_ = $crate::entity::tmdb::episodes::ActiveModel {
|
||||
tmdb_show: Set(::flix_tmdb::model::id::ShowId::from_raw($show)),
|
||||
tmdb_season: Set(::flix_model::numbers::SeasonNumber::new($season)),
|
||||
tmdb_episode: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
|
||||
@@ -385,7 +390,10 @@ mod tests {
|
||||
make_tmdb_collection, make_tmdb_episode, make_tmdb_movie, make_tmdb_season, make_tmdb_show,
|
||||
};
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn use_test_macros() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
@@ -402,8 +410,11 @@ mod tests {
|
||||
make_tmdb_episode!(&db, 1, 1, 1, 1, 1, 1);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_collections() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn round_trip_collections() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_collection {
|
||||
@@ -449,8 +460,11 @@ mod tests {
|
||||
assert_collection!(&db, 6, 6, NotNullViolation; movie_count);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_movies() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn round_trip_movies() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_movie {
|
||||
@@ -502,8 +516,11 @@ mod tests {
|
||||
assert_movie!(&db, 7, 7, None, ForeignKeyViolation; collection_id);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_shows() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn round_trip_shows() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_show {
|
||||
@@ -552,8 +569,11 @@ mod tests {
|
||||
assert_show!(&db, 6, 6, NotNullViolation; number_of_seasons);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_seasons() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn round_trip_seasons() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_season {
|
||||
@@ -607,8 +627,11 @@ mod tests {
|
||||
assert_season!(&db, 1, 7, 1, 7, NotNullViolation; last_update);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_episodes() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn round_trip_episodes() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_episode {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! This module contains entities for storing watched information
|
||||
//! This module contains entities for storing watched information.
|
||||
|
||||
/// Collection entity
|
||||
/// Collection entity.
|
||||
pub mod collections {
|
||||
use flix_model::id::{CollectionId, RawId};
|
||||
|
||||
@@ -9,21 +9,21 @@ pub mod collections {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a watched movie
|
||||
/// The database representation of a watched movie.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_watched_collections")]
|
||||
pub struct Model {
|
||||
/// The collection's ID
|
||||
/// The collection's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: CollectionId,
|
||||
/// The user's ID
|
||||
/// The user's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: RawId,
|
||||
/// The date this collection was watched
|
||||
/// The date this collection was watched.
|
||||
pub watched_date: DateTime<Utc>,
|
||||
|
||||
/// The info for this collection
|
||||
/// The info for this collection.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
relation_enum = "Info",
|
||||
@@ -33,15 +33,16 @@ pub mod collections {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub info: HasOne<entity::info::collections::Entity>,
|
||||
/// The content for this collection
|
||||
/// The content for this collection.
|
||||
#[sea_orm(belongs_to, relation_enum = "Content", from = "id", to = "id", skip_fk)]
|
||||
pub content: HasOne<entity::content::collections::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Movie entity
|
||||
/// Movie entity.
|
||||
pub mod movies {
|
||||
use flix_model::id::{MovieId, RawId};
|
||||
|
||||
@@ -50,21 +51,21 @@ pub mod movies {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a watched movie
|
||||
/// The database representation of a watched movie.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_watched_movies")]
|
||||
pub struct Model {
|
||||
/// The movie's ID
|
||||
/// The movie's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: MovieId,
|
||||
/// The user's ID
|
||||
/// The user's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: RawId,
|
||||
/// The date this movie was watched
|
||||
/// The date this movie was watched.
|
||||
pub watched_date: DateTime<Utc>,
|
||||
|
||||
/// The info for this movie
|
||||
/// The info for this movie.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "id",
|
||||
@@ -73,15 +74,16 @@ pub mod movies {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub info: HasOne<entity::info::movies::Entity>,
|
||||
/// The content for this movie
|
||||
/// The content for this movie.
|
||||
#[sea_orm(belongs_to, relation_enum = "Content", from = "id", to = "id", skip_fk)]
|
||||
pub content: HasOne<entity::content::movies::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Show entity
|
||||
/// Show entity.
|
||||
pub mod shows {
|
||||
use flix_model::id::{RawId, ShowId};
|
||||
|
||||
@@ -90,21 +92,21 @@ pub mod shows {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a watched movie
|
||||
/// The database representation of a watched movie.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_watched_shows")]
|
||||
pub struct Model {
|
||||
/// The show's ID
|
||||
/// The show's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: ShowId,
|
||||
/// The user's ID
|
||||
/// The user's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: RawId,
|
||||
/// The date this show was watched
|
||||
/// The date this show was watched.
|
||||
pub watched_date: DateTime<Utc>,
|
||||
|
||||
/// The info for this show
|
||||
/// The info for this show.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
relation_enum = "Info",
|
||||
@@ -114,15 +116,16 @@ pub mod shows {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub info: HasOne<entity::info::shows::Entity>,
|
||||
/// The content for this show
|
||||
/// The content for this show.
|
||||
#[sea_orm(belongs_to, relation_enum = "Content", from = "id", to = "id", skip_fk)]
|
||||
pub content: HasOne<entity::content::shows::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Season entity
|
||||
/// Season entity.
|
||||
pub mod seasons {
|
||||
use flix_model::id::{RawId, ShowId};
|
||||
use flix_model::numbers::SeasonNumber;
|
||||
@@ -132,24 +135,24 @@ pub mod seasons {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a watched movie
|
||||
/// The database representation of a watched movie.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_watched_seasons")]
|
||||
pub struct Model {
|
||||
/// The season's show's ID
|
||||
/// The season's show's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub show_id: ShowId,
|
||||
/// The season's number
|
||||
/// The season's number.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub season_number: SeasonNumber,
|
||||
/// The user's ID
|
||||
/// The user's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: RawId,
|
||||
/// The date this season was watched
|
||||
/// The date this season was watched.
|
||||
pub watched_date: DateTime<Utc>,
|
||||
|
||||
/// The info for this season
|
||||
/// The info for this season.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
relation_enum = "Info",
|
||||
@@ -159,7 +162,7 @@ pub mod seasons {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub info: HasOne<entity::info::seasons::Entity>,
|
||||
/// The content for this season
|
||||
/// The content for this season.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
relation_enum = "Content",
|
||||
@@ -170,10 +173,11 @@ pub mod seasons {
|
||||
pub content: HasOne<entity::content::seasons::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Episode entity
|
||||
/// Episode entity.
|
||||
pub mod episodes {
|
||||
use flix_model::id::{RawId, ShowId};
|
||||
use flix_model::numbers::{EpisodeNumber, SeasonNumber};
|
||||
@@ -183,27 +187,27 @@ pub mod episodes {
|
||||
|
||||
use crate::entity;
|
||||
|
||||
/// The database representation of a watched movie
|
||||
/// The database representation of a watched movie.
|
||||
#[sea_orm::model]
|
||||
#[derive(Debug, Clone, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "flix_watched_episodes")]
|
||||
pub struct Model {
|
||||
/// The episode's show's ID
|
||||
/// The episode's show's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub show_id: ShowId,
|
||||
/// The episode's season's number
|
||||
/// The episode's season's number.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub season_number: SeasonNumber,
|
||||
/// The episode's number
|
||||
/// The episode's number.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub episode_number: EpisodeNumber,
|
||||
/// The user's ID
|
||||
/// The user's ID.
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: RawId,
|
||||
/// The date this episode was watched
|
||||
/// The date this episode was watched.
|
||||
pub watched_date: DateTime<Utc>,
|
||||
|
||||
/// The info for this episode
|
||||
/// The info for this episode.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
relation_enum = "Info",
|
||||
@@ -213,7 +217,7 @@ pub mod episodes {
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub info: HasOne<entity::info::episodes::Entity>,
|
||||
/// The content for this episode
|
||||
/// The content for this episode.
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
relation_enum = "Content",
|
||||
@@ -224,15 +228,16 @@ pub mod episodes {
|
||||
pub content: HasOne<entity::content::episodes::Entity>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Macros for creating watched entities
|
||||
/// Macros for creating watched entities.
|
||||
#[cfg(test)]
|
||||
pub mod test {
|
||||
macro_rules! make_watched_movie {
|
||||
($db:expr, $id:expr, $user:expr) => {
|
||||
$crate::entity::watched::movies::ActiveModel {
|
||||
_ = $crate::entity::watched::movies::ActiveModel {
|
||||
id: Set(::flix_model::id::MovieId::from_raw($id)),
|
||||
user_id: Set($user),
|
||||
watched_date: Set(::chrono::Utc::now()),
|
||||
@@ -246,7 +251,7 @@ pub mod test {
|
||||
|
||||
macro_rules! make_watched_episode {
|
||||
($db:expr, $show:expr, $season:expr, $episode:expr, $user:expr) => {
|
||||
$crate::entity::watched::episodes::ActiveModel {
|
||||
_ = $crate::entity::watched::episodes::ActiveModel {
|
||||
show_id: Set(::flix_model::id::ShowId::from_raw($show)),
|
||||
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
|
||||
episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
|
||||
@@ -284,7 +289,10 @@ mod tests {
|
||||
use super::super::tests::notsettable;
|
||||
use super::test::{make_watched_episode, make_watched_movie};
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn use_test_macros() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
@@ -297,8 +305,11 @@ mod tests {
|
||||
make_watched_episode!(&db, 1, 1, 1, 1);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_movies() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn round_trip_movies() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_movie {
|
||||
@@ -340,8 +351,11 @@ mod tests {
|
||||
assert_movie!(&db, 5, 1, NotNullViolation; watched_date);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_round_trip_episodes() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn round_trip_episodes() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_episode {
|
||||
@@ -394,13 +408,16 @@ mod tests {
|
||||
assert_episode!(&db, 7, 1, 1, 1, NotNullViolation; watched_date);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_query_seasons() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn query_seasons() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_season {
|
||||
($db:expr, $show:literal, $season:literal, $uid:literal, Watched) => {
|
||||
assert_season!(@find, $db, $show, $season, $uid)
|
||||
_ = assert_season!(@find, $db, $show, $season, $uid)
|
||||
.ok_or(())
|
||||
.expect("is none");
|
||||
};
|
||||
@@ -462,13 +479,16 @@ mod tests {
|
||||
assert_season!(&db, 1, 2, 3, Unwatched);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_query_shows() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn query_shows() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_show {
|
||||
($db:expr, $show:literal, $uid:literal, Watched) => {
|
||||
assert_show!(@find, $db, $show, $uid)
|
||||
_ = assert_show!(@find, $db, $show, $uid)
|
||||
.ok_or(())
|
||||
.expect("is none");
|
||||
};
|
||||
@@ -517,13 +537,16 @@ mod tests {
|
||||
assert_show!(&db, 1, 2, Unwatched);
|
||||
}
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[tokio::test]
|
||||
async fn test_query_collections() {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
|
||||
async fn query_collections() {
|
||||
let db = new_initialized_memory_db().await;
|
||||
|
||||
macro_rules! assert_collection {
|
||||
($db:expr, $id:literal, $uid:literal, Watched) => {
|
||||
assert_collection!(@find, $db, $id, $uid)
|
||||
_ = assert_collection!(@find, $db, $id, $uid)
|
||||
.ok_or(())
|
||||
.expect("is none");
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! flix-db provides types for storing persistent data about media
|
||||
//! flix-db provides types for storing persistent data about media.
|
||||
|
||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
|
||||
#[cfg(test)]
|
||||
extern crate alloc;
|
||||
|
||||
pub mod connection;
|
||||
pub mod entity;
|
||||
pub mod migration;
|
||||
@@ -12,7 +15,8 @@ mod tests {
|
||||
|
||||
use crate::connection::Connection;
|
||||
|
||||
pub async fn new_initialized_memory_db() -> DatabaseConnection {
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
pub(crate) async fn new_initialized_memory_db() -> DatabaseConnection {
|
||||
let options = ConnectOptions::new("sqlite::memory:");
|
||||
|
||||
let db = Database::connect(options)
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
//! Custom views for collections.
|
||||
|
||||
use sea_orm::prelude::*;
|
||||
use sea_orm::sea_query::Table;
|
||||
use sea_orm::{ConnectionTrait, DbBackend, Statement};
|
||||
use sea_orm::{DbBackend, Statement};
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
|
||||
/// # Errors
|
||||
/// Database operations can fail.
|
||||
pub(crate) async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table("flix_watched_collections").to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
_ = manager
|
||||
.get_connection()
|
||||
.execute_raw(Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
r#"
|
||||
"
|
||||
CREATE VIEW flix_watched_collections AS
|
||||
WITH RECURSIVE
|
||||
watched_items AS (
|
||||
@@ -81,22 +85,24 @@ pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
|
||||
)
|
||||
GROUP BY ci.parent_id, wi.user_id
|
||||
;
|
||||
"#,
|
||||
",
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
|
||||
manager
|
||||
/// # Errors
|
||||
/// Database operations can fail.
|
||||
pub(crate) async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
|
||||
_ = manager
|
||||
.get_connection()
|
||||
.execute_raw(Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
r#"
|
||||
"
|
||||
DROP VIEW flix_watched_collections
|
||||
;
|
||||
"#,
|
||||
",
|
||||
))
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -11,10 +11,16 @@ mod collections;
|
||||
mod seasons;
|
||||
mod shows;
|
||||
|
||||
/// The migration entry point.
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub(super) struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
#[expect(
|
||||
elided_lifetimes_in_paths,
|
||||
reason = "async_trait causes lifetimes to be strange"
|
||||
)]
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
seasons::up(manager).await?;
|
||||
@@ -1,18 +1,22 @@
|
||||
//! Custom views for seasons.
|
||||
|
||||
use sea_orm::prelude::*;
|
||||
use sea_orm::sea_query::Table;
|
||||
use sea_orm::{ConnectionTrait, DbBackend, Statement};
|
||||
use sea_orm::{DbBackend, Statement};
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
|
||||
/// # Errors
|
||||
/// Database operations can fail.
|
||||
pub(crate) async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table("flix_watched_seasons").to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
_ = manager
|
||||
.get_connection()
|
||||
.execute_raw(Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
r#"
|
||||
"
|
||||
CREATE VIEW flix_watched_seasons AS
|
||||
SELECT
|
||||
w.show_id,
|
||||
@@ -36,22 +40,24 @@ pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
|
||||
)
|
||||
GROUP BY w.show_id, w.season_number, w.user_id
|
||||
;
|
||||
"#,
|
||||
",
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
|
||||
manager
|
||||
/// # Errors
|
||||
/// Database operations can fail.
|
||||
pub(crate) async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
|
||||
_ = manager
|
||||
.get_connection()
|
||||
.execute_raw(Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
r#"
|
||||
"
|
||||
DROP VIEW flix_watched_seasons
|
||||
;
|
||||
"#,
|
||||
",
|
||||
))
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
//! Custom views for shows.
|
||||
|
||||
use sea_orm::prelude::*;
|
||||
use sea_orm::sea_query::Table;
|
||||
use sea_orm::{ConnectionTrait, DbBackend, Statement};
|
||||
use sea_orm::{DbBackend, Statement};
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
|
||||
/// # Errors
|
||||
/// Database operations can fail.
|
||||
pub(crate) async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table("flix_watched_shows").to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
_ = manager
|
||||
.get_connection()
|
||||
.execute_raw(Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
r#"
|
||||
"
|
||||
CREATE VIEW flix_watched_shows AS
|
||||
SELECT
|
||||
w.show_id as id,
|
||||
@@ -33,22 +37,24 @@ pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
|
||||
)
|
||||
GROUP BY w.show_id, w.user_id
|
||||
;
|
||||
"#,
|
||||
",
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
|
||||
manager
|
||||
/// # Errors
|
||||
/// Database operations can fail.
|
||||
pub(crate) async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
|
||||
_ = manager
|
||||
.get_connection()
|
||||
.execute_raw(Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
r#"
|
||||
"
|
||||
DROP VIEW flix_watched_shows
|
||||
;
|
||||
"#,
|
||||
",
|
||||
))
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Migrations for maintaining the database schema
|
||||
//! Migrations for maintaining the database schema.
|
||||
|
||||
seamantic::migrations! {
|
||||
"seaql_migrations_flix";
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
[package]
|
||||
name = "flix"
|
||||
version = "0.0.20"
|
||||
license-file.workspace = true
|
||||
|
||||
version = "0.1.0"
|
||||
description = "Mechanisms for interacting with 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! flix provides mechanisms for interacting with flix media
|
||||
//! flix provides mechanisms for interacting with flix media.
|
||||
|
||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
|
||||
|
||||
+10
-5
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,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))]
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
[package]
|
||||
name = "flix-model"
|
||||
version = "0.0.20"
|
||||
license-file.workspace = true
|
||||
|
||||
version = "0.1.0"
|
||||
description = "Core types for flix data"
|
||||
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
|
||||
|
||||
+29
-6
@@ -1,26 +1,49 @@
|
||||
//! This module contains types relating to flix media IDs
|
||||
//! This module contains types relating to flix media IDs.
|
||||
|
||||
#![expect(clippy::module_name_repetitions, reason = "ID types end with Id")]
|
||||
|
||||
use seamantic::model::id::Id;
|
||||
|
||||
/// Type alias for the raw ID representation
|
||||
/// Type alias for the raw ID representation.
|
||||
pub use seamantic::model::id::SeaOrmRepr as RawId;
|
||||
|
||||
#[doc(hidden)]
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Library {}
|
||||
/// Type alias for a library ID
|
||||
/// Type alias for a library ID.
|
||||
pub type LibraryId = Id<Library>;
|
||||
|
||||
#[doc(hidden)]
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Collection {}
|
||||
/// Type alias for a collection ID
|
||||
/// Type alias for a collection ID.
|
||||
pub type CollectionId = Id<Collection>;
|
||||
|
||||
#[doc(hidden)]
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Movie {}
|
||||
/// Type alias for a movie ID
|
||||
/// Type alias for a movie ID.
|
||||
pub type MovieId = Id<Movie>;
|
||||
|
||||
#[doc(hidden)]
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Show {}
|
||||
/// Type alias for a show ID
|
||||
/// Type alias for a show ID.
|
||||
pub type ShowId = Id<Show>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CollectionId, LibraryId, MovieId, ShowId};
|
||||
|
||||
#[test]
|
||||
fn use_types() {
|
||||
_ = CollectionId::from_raw(0);
|
||||
_ = LibraryId::from_raw(0);
|
||||
_ = MovieId::from_raw(0);
|
||||
_ = ShowId::from_raw(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! flix-model provides core types for flix data
|
||||
//! flix-model provides core types for flix data.
|
||||
|
||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
|
||||
|
||||
+60
-26
@@ -1,4 +1,4 @@
|
||||
//! This module contains season and episode numbers and related errors
|
||||
//! This module contains season and episode numbers and related errors.
|
||||
|
||||
use core::fmt;
|
||||
use core::ops::RangeInclusive;
|
||||
@@ -7,7 +7,7 @@ use std::collections::HashSet;
|
||||
|
||||
use seamantic::sea_orm;
|
||||
|
||||
/// Newtype for representing season numbers
|
||||
/// Newtype for representing season numbers.
|
||||
#[derive(
|
||||
Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, sea_orm::DeriveValueType,
|
||||
)]
|
||||
@@ -17,13 +17,16 @@ use seamantic::sea_orm;
|
||||
pub struct SeasonNumber(u32);
|
||||
|
||||
impl SeasonNumber {
|
||||
/// Create a `SeasonNumber` from an integer
|
||||
pub fn new(value: u32) -> Self {
|
||||
/// Create a [`SeasonNumber`] from an integer.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub const fn new(value: u32) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SeasonNumber {
|
||||
#[inline]
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
@@ -32,12 +35,13 @@ impl fmt::Display for SeasonNumber {
|
||||
impl FromStr for SeasonNumber {
|
||||
type Err = <u32 as FromStr>::Err;
|
||||
|
||||
#[inline]
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
u32::from_str(s).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Newtype for representing episode numbers
|
||||
/// Newtype for representing episode numbers.
|
||||
#[derive(
|
||||
Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, sea_orm::DeriveValueType,
|
||||
)]
|
||||
@@ -47,18 +51,23 @@ impl FromStr for SeasonNumber {
|
||||
pub struct EpisodeNumber(u32);
|
||||
|
||||
impl EpisodeNumber {
|
||||
/// Create an `EpisodeNumber` from an integer
|
||||
pub fn new(value: u32) -> Self {
|
||||
/// Create an [`EpisodeNumber`] from an integer.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub const fn new(value: u32) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
/// Get the underlying value
|
||||
pub fn into_inner(self) -> u32 {
|
||||
/// Get the underlying value.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub const fn into_inner(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EpisodeNumber {
|
||||
#[inline]
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
@@ -67,33 +76,40 @@ impl fmt::Display for EpisodeNumber {
|
||||
impl FromStr for EpisodeNumber {
|
||||
type Err = <u32 as FromStr>::Err;
|
||||
|
||||
#[inline]
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
u32::from_str(s).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Potential errors when building EpisodeNumbers
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// There are no episodes
|
||||
/// Potential errors when building [`EpisodeNumbers`].
|
||||
#[derive(Debug, Clone, Copy, thiserror::Error)]
|
||||
#[expect(clippy::exhaustive_enums, reason = "unlikely to add new variants")]
|
||||
pub enum EpisodeNumbersError {
|
||||
/// There are no episodes.
|
||||
#[error("zero episodes")]
|
||||
Zero,
|
||||
/// There are gaps in the episodes
|
||||
/// There are gaps in the episodes.
|
||||
#[error("noncontiguous episodes")]
|
||||
Noncontiguous,
|
||||
}
|
||||
|
||||
/// A wrapper for handling single and multi-episode entries
|
||||
/// A wrapper for handling single and multi-episode entries.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
#[expect(
|
||||
clippy::module_name_repetitions,
|
||||
reason = "Episodes is not a good name"
|
||||
)]
|
||||
pub struct EpisodeNumbers(RangeInclusive<EpisodeNumber>);
|
||||
|
||||
impl TryFrom<&[EpisodeNumber]> for EpisodeNumbers {
|
||||
type Error = Error;
|
||||
type Error = EpisodeNumbersError;
|
||||
|
||||
#[inline]
|
||||
fn try_from(value: &[EpisodeNumber]) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
[] => Err(Error::Zero),
|
||||
[] => Err(EpisodeNumbersError::Zero),
|
||||
[n] => Ok(Self(*n..=*n)),
|
||||
_ => {
|
||||
// min and max will always exist
|
||||
@@ -102,12 +118,12 @@ impl TryFrom<&[EpisodeNumber]> for EpisodeNumbers {
|
||||
let len = value.len();
|
||||
|
||||
if usize::try_from(max.0.saturating_sub(min.0).saturating_add(1)) != Ok(len) {
|
||||
return Err(Error::Noncontiguous);
|
||||
return Err(EpisodeNumbersError::Noncontiguous);
|
||||
}
|
||||
|
||||
let set: HashSet<_> = value.iter().copied().collect();
|
||||
if set.len() != len {
|
||||
return Err(Error::Noncontiguous);
|
||||
return Err(EpisodeNumbersError::Noncontiguous);
|
||||
}
|
||||
|
||||
Ok(Self(min..=max))
|
||||
@@ -117,27 +133,45 @@ impl TryFrom<&[EpisodeNumber]> for EpisodeNumbers {
|
||||
}
|
||||
|
||||
impl EpisodeNumbers {
|
||||
/// Create an [EpisodeNumbers] from a starting number and a count.
|
||||
/// Create an [`EpisodeNumbers`] from a starting number and a count.
|
||||
/// `count` should be zero for single episodes.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn new(start: EpisodeNumber, count: u8) -> Self {
|
||||
Self(start..=EpisodeNumber(start.0.saturating_add(count.into())))
|
||||
}
|
||||
|
||||
/// Get the range of episodes
|
||||
pub fn as_range(&self) -> &RangeInclusive<EpisodeNumber> {
|
||||
/// Get the range of episodes.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub const fn as_range(&self) -> &RangeInclusive<EpisodeNumber> {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Render this [EpisodeNumbers] as a range. If only one episode is
|
||||
/// is present it renders as `01`, if multiple it renders as `01-02`
|
||||
/// Render this [`EpisodeNumbers`] as a range. If only one episode is
|
||||
/// is present it renders as `01`, if multiple it renders as `01-02`.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn range_string(&self) -> String {
|
||||
let start = self.0.start();
|
||||
let end = self.0.end();
|
||||
|
||||
if start == end {
|
||||
format!("{:02}", start)
|
||||
format!("{start:02}")
|
||||
} else {
|
||||
format!("{:02}-{:02}", start, end)
|
||||
format!("{start:02}-{end:02}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{EpisodeNumber, EpisodeNumbers, SeasonNumber};
|
||||
|
||||
#[test]
|
||||
fn use_fn() {
|
||||
_ = SeasonNumber::new(0);
|
||||
let e = EpisodeNumber::new(0);
|
||||
_ = EpisodeNumbers::new(e, 1);
|
||||
}
|
||||
}
|
||||
|
||||
+48
-13
@@ -1,22 +1,26 @@
|
||||
//! This module contains helper functions for normalizing media titles
|
||||
//! This module contains helper functions for normalizing media titles.
|
||||
|
||||
use core::iter::Peekable;
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
/// Return an iterator over the normalized words of a string.
|
||||
/// - Alphanumeric
|
||||
/// - Lowercase
|
||||
/// - Replace `&` with `and`
|
||||
/// - Collapse acronyms
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `input` is not ASCII.
|
||||
fn split_normalized_words(input: &str) -> impl Iterator<Item = String> {
|
||||
if !input.is_ascii() {
|
||||
panic!("Input is not ASCII: {input}");
|
||||
}
|
||||
assert!(input.is_ascii(), "input is not ASCII: {input}");
|
||||
|
||||
input
|
||||
.split_ascii_whitespace()
|
||||
.map(|s| {
|
||||
if s == "&" {
|
||||
return "and".to_string();
|
||||
return "and".to_owned();
|
||||
}
|
||||
|
||||
let chars = s
|
||||
@@ -37,6 +41,7 @@ fn split_normalized_words(input: &str) -> impl Iterator<Item = String> {
|
||||
.filter(|part: &String| !part.is_empty() && part != "-")
|
||||
}
|
||||
|
||||
/// Split out `a`, `an`, and `the` from the start of the string if it exists.
|
||||
fn split_leading_article<I: Iterator<Item = String>>(iter: I) -> (Option<String>, Peekable<I>) {
|
||||
let mut iter = iter.peekable();
|
||||
match iter.peek().map(String::as_str) {
|
||||
@@ -57,13 +62,15 @@ fn split_leading_article<I: Iterator<Item = String>>(iter: I) -> (Option<String>
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `input` is not ASCII.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn make_sortable_title(title: &str) -> String {
|
||||
let words = split_normalized_words(title);
|
||||
let (article, words) = split_leading_article(words);
|
||||
|
||||
let output = Itertools::intersperse(words, " ".to_string());
|
||||
let output = Itertools::intersperse(words, " ".to_owned());
|
||||
if let Some(article) = article {
|
||||
output.chain([", ".to_string(), article]).collect()
|
||||
output.chain([", ".to_owned(), article]).collect()
|
||||
} else {
|
||||
output.collect()
|
||||
}
|
||||
@@ -81,11 +88,13 @@ pub fn make_sortable_title(title: &str) -> String {
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `input` is not ASCII.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn make_fs_slug(title: &str) -> String {
|
||||
let words = split_normalized_words(title);
|
||||
let (_, words) = split_leading_article(words);
|
||||
|
||||
Itertools::intersperse(words, " ".to_string()).collect()
|
||||
Itertools::intersperse(words, " ".to_owned()).collect()
|
||||
}
|
||||
|
||||
/// Convert a media title and year to a folder name representable on filesystems
|
||||
@@ -100,11 +109,13 @@ pub fn make_fs_slug(title: &str) -> String {
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `input` is not ASCII.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn make_fs_slug_year(title: &str, year: i32) -> String {
|
||||
let words = split_normalized_words(title);
|
||||
let (_, words) = split_leading_article(words);
|
||||
|
||||
Itertools::intersperse(words, " ".to_string())
|
||||
Itertools::intersperse(words, " ".to_owned())
|
||||
.chain([format!(" ({year})")])
|
||||
.collect()
|
||||
}
|
||||
@@ -117,10 +128,12 @@ pub fn make_fs_slug_year(title: &str, year: i32) -> String {
|
||||
/// assert_eq!(normalize_fs_name("Marvel's Agents of SHIELD (2013)"), "marvels agents of shield (2013)");
|
||||
/// assert_eq!(normalize_fs_name("Avatar The Last Airbender (2005)"), "avatar the last airbender (2005)");
|
||||
/// assert_eq!(normalize_fs_name("Cloak & Dagger (2018)"), "cloak and dagger (2018)");
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn normalize_fs_name(input: &str) -> String {
|
||||
let chars = input.split_ascii_whitespace().map(|s| {
|
||||
if s == "&" {
|
||||
return "and".to_string();
|
||||
return "and".to_owned();
|
||||
}
|
||||
|
||||
let chars = s
|
||||
@@ -138,7 +151,7 @@ pub fn normalize_fs_name(input: &str) -> String {
|
||||
chars.collect()
|
||||
}
|
||||
});
|
||||
Itertools::intersperse(chars, " ".to_string()).collect()
|
||||
Itertools::intersperse(chars, " ".to_owned()).collect()
|
||||
}
|
||||
|
||||
/// Convert a media title to a url compatible string
|
||||
@@ -153,11 +166,13 @@ pub fn normalize_fs_name(input: &str) -> String {
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `input` is not ASCII.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn make_web_slug(title: &str) -> String {
|
||||
let words = split_normalized_words(title);
|
||||
let (_, words) = split_leading_article(words);
|
||||
|
||||
Itertools::intersperse(words, "-".to_string()).collect()
|
||||
Itertools::intersperse(words, "-".to_owned()).collect()
|
||||
}
|
||||
|
||||
/// Convert a media title and year to a url compatible string
|
||||
@@ -172,11 +187,31 @@ pub fn make_web_slug(title: &str) -> String {
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `input` is not ASCII.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn make_web_slug_year(title: &str, year: i32) -> String {
|
||||
let words = split_normalized_words(title);
|
||||
let (_, words) = split_leading_article(words);
|
||||
|
||||
Itertools::intersperse(words, "-".to_string())
|
||||
Itertools::intersperse(words, "-".to_owned())
|
||||
.chain([format!("-{year}")])
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
make_fs_slug, make_fs_slug_year, make_sortable_title, make_web_slug, make_web_slug_year,
|
||||
normalize_fs_name,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn use_fn() {
|
||||
drop(make_sortable_title(""));
|
||||
drop(make_fs_slug(""));
|
||||
drop(make_fs_slug_year("", 0));
|
||||
drop(normalize_fs_name(""));
|
||||
drop(make_web_slug(""));
|
||||
drop(make_web_slug_year("", 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
[package]
|
||||
name = "flix-tmdb"
|
||||
version = "0.0.20"
|
||||
license-file.workspace = true
|
||||
|
||||
version = "0.1.0"
|
||||
description = "Clients and models for fetching data from TMDB"
|
||||
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
|
||||
@@ -32,6 +34,7 @@ sea-orm = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_test = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Collections API
|
||||
//! Collections API.
|
||||
|
||||
use std::rc::Rc;
|
||||
use alloc::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::api::exec_request;
|
||||
@@ -10,26 +10,38 @@ use crate::{Cache, CachePolicy, Config};
|
||||
|
||||
use super::{Error, make_request};
|
||||
|
||||
/// TMDB Collections API client
|
||||
/// TMDB Collections API client.
|
||||
#[derive(Debug)]
|
||||
pub struct Client {
|
||||
config: Rc<Config>,
|
||||
cache: Rc<dyn Cache>,
|
||||
policy: Rc<RwLock<CachePolicy>>,
|
||||
/// The client configuration.
|
||||
config: Arc<Config>,
|
||||
/// The request cache.
|
||||
cache: Arc<dyn Cache>,
|
||||
/// The cache policy to use for requests.
|
||||
policy: Arc<RwLock<CachePolicy>>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Create a new client with the given configuration
|
||||
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
|
||||
/// Create a new client with the given configuration.
|
||||
#[inline]
|
||||
pub fn new(
|
||||
config: Arc<Config>,
|
||||
cache: Arc<dyn Cache>,
|
||||
policy: Arc<RwLock<CachePolicy>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
cache,
|
||||
policy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Fetch the details of the collection refered to by ID
|
||||
/// Fetch the details of the collection refered to by ID.
|
||||
///
|
||||
/// # Errors
|
||||
/// See [Error] for failure modes.
|
||||
#[inline]
|
||||
#[expect(clippy::impl_trait_in_params, reason = "turbofish is not expected")]
|
||||
pub async fn get_details(
|
||||
&self,
|
||||
id: impl Into<CollectionId>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Episodes API
|
||||
//! Episodes API.
|
||||
|
||||
use std::rc::Rc;
|
||||
use alloc::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use flix_model::numbers::{EpisodeNumber, SeasonNumber};
|
||||
@@ -12,26 +12,38 @@ use crate::{Cache, CachePolicy, Config};
|
||||
|
||||
use super::{Error, make_request};
|
||||
|
||||
/// TMDB Episodes API client
|
||||
/// TMDB Episodes API client.
|
||||
#[derive(Debug)]
|
||||
pub struct Client {
|
||||
config: Rc<Config>,
|
||||
cache: Rc<dyn Cache>,
|
||||
policy: Rc<RwLock<CachePolicy>>,
|
||||
/// The client configuration.
|
||||
config: Arc<Config>,
|
||||
/// The request cache.
|
||||
cache: Arc<dyn Cache>,
|
||||
/// The cache policy to use for requests.
|
||||
policy: Arc<RwLock<CachePolicy>>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Create a new client with the given configuration
|
||||
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
|
||||
/// Create a new client with the given configuration.
|
||||
#[inline]
|
||||
pub fn new(
|
||||
config: Arc<Config>,
|
||||
cache: Arc<dyn Cache>,
|
||||
policy: Arc<RwLock<CachePolicy>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
cache,
|
||||
policy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Fetch the details of the episode refered to by ID, season number and episode number
|
||||
/// Fetch the details of the episode refered to by ID, season number and episode number.
|
||||
///
|
||||
/// # Errors
|
||||
/// See [Error] for failure modes.
|
||||
#[inline]
|
||||
#[expect(clippy::impl_trait_in_params, reason = "turbofish is not expected")]
|
||||
pub async fn get_details(
|
||||
&self,
|
||||
id: impl Into<ShowId>,
|
||||
|
||||
+49
-35
@@ -1,6 +1,5 @@
|
||||
//! TMDB API clients
|
||||
//! TMDB API clients.
|
||||
|
||||
use core::ops::Deref;
|
||||
use core::time::Duration;
|
||||
use std::sync::RwLock;
|
||||
|
||||
@@ -17,20 +16,26 @@ pub mod movies;
|
||||
pub mod seasons;
|
||||
pub mod shows;
|
||||
|
||||
/// A generic error wrapping Url and Reqwest errors
|
||||
/// A generic error wrapping Url and Reqwest errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[expect(clippy::error_impl_error, reason = "Error is a good name here")]
|
||||
#[expect(clippy::exhaustive_enums, reason = "unlikely to add new variants")]
|
||||
pub enum Error {
|
||||
/// Url error wrapper
|
||||
/// Url error wrapper.
|
||||
#[error("url parse error: {0}")]
|
||||
Url(#[from] url::ParseError),
|
||||
/// Reqwest error wrapper
|
||||
/// Reqwest error wrapper.
|
||||
#[error("reqwest error: {0}")]
|
||||
Reqwest(#[from] reqwest::Error),
|
||||
/// Json error wrapper
|
||||
/// Json error wrapper.
|
||||
#[error("json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// Turn a [Config] and `path` into a [Request].
|
||||
///
|
||||
/// # Errors
|
||||
/// See [Error] for failure modes.
|
||||
fn make_request(config: &Config, path: &str, language: Option<&str>) -> Result<Request, Error> {
|
||||
let url = config.base_url.join(path)?;
|
||||
|
||||
@@ -38,7 +43,7 @@ fn make_request(config: &Config, path: &str, language: Option<&str>) -> Result<R
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {}", config.bearer_token),
|
||||
);
|
||||
if let Some(ref user_agent) = config.user_agent {
|
||||
if let Some(user_agent) = &config.user_agent {
|
||||
builder = builder.header(header::USER_AGENT, user_agent);
|
||||
}
|
||||
if let Some(language) = language {
|
||||
@@ -48,22 +53,22 @@ fn make_request(config: &Config, path: &str, language: Option<&str>) -> Result<R
|
||||
Ok(builder.build()?)
|
||||
}
|
||||
|
||||
/// Execute a [Request] and deserialize the response.
|
||||
///
|
||||
/// # Errors
|
||||
/// See [Error] for failure modes.
|
||||
async fn exec_request<T: DeserializeOwned>(
|
||||
config: &Config,
|
||||
cache: &dyn Cache,
|
||||
policy: &RwLock<CachePolicy>,
|
||||
request: Request,
|
||||
) -> Result<T, Error> {
|
||||
let (read_cache, write_cache) = if let Ok(guard) = policy.read() {
|
||||
match guard.deref() {
|
||||
CachePolicy::None => (None, None),
|
||||
CachePolicy::Full => (Some(cache), Some(cache)),
|
||||
CachePolicy::Read => (Some(cache), None),
|
||||
CachePolicy::Update => (None, Some(cache)),
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let (read_cache, write_cache) = policy.read().map_or((None, None), |guard| match *guard {
|
||||
CachePolicy::None => (None, None),
|
||||
CachePolicy::Full => (Some(cache), Some(cache)),
|
||||
CachePolicy::Read => (Some(cache), None),
|
||||
CachePolicy::Update => (None, Some(cache)),
|
||||
});
|
||||
|
||||
let path = request.url().path().to_owned();
|
||||
|
||||
@@ -73,24 +78,23 @@ async fn exec_request<T: DeserializeOwned>(
|
||||
response = cache.get(&path);
|
||||
}
|
||||
let needs_cache_write = response.is_none();
|
||||
let response = match response {
|
||||
Some(response) => response,
|
||||
None => {
|
||||
config
|
||||
.limiter
|
||||
.until_ready_with_jitter(Jitter::new(
|
||||
Duration::from_millis(0),
|
||||
Duration::from_millis(50),
|
||||
))
|
||||
.await;
|
||||
config
|
||||
.client
|
||||
.execute(request)
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.bytes()
|
||||
.await?
|
||||
}
|
||||
let response = if let Some(response) = response {
|
||||
response
|
||||
} else {
|
||||
config
|
||||
.limiter
|
||||
.until_ready_with_jitter(Jitter::new(
|
||||
Duration::from_millis(0),
|
||||
Duration::from_millis(50),
|
||||
))
|
||||
.await;
|
||||
config
|
||||
.client
|
||||
.execute(request)
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.bytes()
|
||||
.await?
|
||||
};
|
||||
|
||||
// write to the cache if needed
|
||||
@@ -102,3 +106,13 @@ async fn exec_request<T: DeserializeOwned>(
|
||||
|
||||
Ok(serde_json::from_slice(&response)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Error;
|
||||
|
||||
#[test]
|
||||
fn use_types() {
|
||||
drop(Error::Url(url::ParseError::Overflow));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Movies API
|
||||
//! Movies API.
|
||||
|
||||
use std::rc::Rc;
|
||||
use alloc::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::api::exec_request;
|
||||
@@ -10,26 +10,38 @@ use crate::{Cache, CachePolicy, Config};
|
||||
|
||||
use super::{Error, make_request};
|
||||
|
||||
/// TMDB Movies API client
|
||||
/// TMDB Movies API client.
|
||||
#[derive(Debug)]
|
||||
pub struct Client {
|
||||
config: Rc<Config>,
|
||||
cache: Rc<dyn Cache>,
|
||||
policy: Rc<RwLock<CachePolicy>>,
|
||||
/// The client configuration.
|
||||
config: Arc<Config>,
|
||||
/// The request cache.
|
||||
cache: Arc<dyn Cache>,
|
||||
/// The cache policy to use for requests.
|
||||
policy: Arc<RwLock<CachePolicy>>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Create a new client with the given configuration
|
||||
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
|
||||
/// Create a new client with the given configuration.
|
||||
#[inline]
|
||||
pub fn new(
|
||||
config: Arc<Config>,
|
||||
cache: Arc<dyn Cache>,
|
||||
policy: Arc<RwLock<CachePolicy>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
cache,
|
||||
policy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Fetch the details of the movie refered to by ID
|
||||
/// Fetch the details of the movie refered to by ID.
|
||||
///
|
||||
/// # Errors
|
||||
/// See [Error] for failure modes.
|
||||
#[inline]
|
||||
#[expect(clippy::impl_trait_in_params, reason = "turbofish is not expected")]
|
||||
pub async fn get_details(
|
||||
&self,
|
||||
id: impl Into<MovieId>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Seasons API
|
||||
//! Seasons API.
|
||||
|
||||
use std::rc::Rc;
|
||||
use alloc::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use flix_model::numbers::SeasonNumber;
|
||||
@@ -12,26 +12,38 @@ use crate::{Cache, CachePolicy, Config};
|
||||
|
||||
use super::{Error, make_request};
|
||||
|
||||
/// TMDB Seasons API client
|
||||
/// TMDB Seasons API client.
|
||||
#[derive(Debug)]
|
||||
pub struct Client {
|
||||
config: Rc<Config>,
|
||||
cache: Rc<dyn Cache>,
|
||||
policy: Rc<RwLock<CachePolicy>>,
|
||||
/// The client configuration.
|
||||
config: Arc<Config>,
|
||||
/// The request cache.
|
||||
cache: Arc<dyn Cache>,
|
||||
/// The cache policy to use for requests.
|
||||
policy: Arc<RwLock<CachePolicy>>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Create a new client with the given configuration
|
||||
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
|
||||
/// Create a new client with the given configuration.
|
||||
#[inline]
|
||||
pub fn new(
|
||||
config: Arc<Config>,
|
||||
cache: Arc<dyn Cache>,
|
||||
policy: Arc<RwLock<CachePolicy>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
cache,
|
||||
policy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Fetch the details of the season refered to by ID and season number
|
||||
/// Fetch the details of the season refered to by ID and season number.
|
||||
///
|
||||
/// # Errors
|
||||
/// See [Error] for failure modes.
|
||||
#[inline]
|
||||
#[expect(clippy::impl_trait_in_params, reason = "turbofish is not expected")]
|
||||
pub async fn get_details(
|
||||
&self,
|
||||
id: impl Into<ShowId>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Shows API
|
||||
//! Shows API.
|
||||
|
||||
use std::rc::Rc;
|
||||
use alloc::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::api::exec_request;
|
||||
@@ -10,26 +10,38 @@ use crate::{Cache, CachePolicy, Config};
|
||||
|
||||
use super::{Error, make_request};
|
||||
|
||||
/// TMDB Shows API client
|
||||
/// TMDB Shows API client.
|
||||
#[derive(Debug)]
|
||||
pub struct Client {
|
||||
config: Rc<Config>,
|
||||
cache: Rc<dyn Cache>,
|
||||
policy: Rc<RwLock<CachePolicy>>,
|
||||
/// The client configuration.
|
||||
config: Arc<Config>,
|
||||
/// The request cache.
|
||||
cache: Arc<dyn Cache>,
|
||||
/// The cache policy to use for requests.
|
||||
policy: Arc<RwLock<CachePolicy>>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Create a new client with the given configuration
|
||||
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
|
||||
/// Create a new client with the given configuration.
|
||||
#[inline]
|
||||
pub fn new(
|
||||
config: Arc<Config>,
|
||||
cache: Arc<dyn Cache>,
|
||||
policy: Arc<RwLock<CachePolicy>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
cache,
|
||||
policy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Fetch the details of the show refered to by ID
|
||||
/// Fetch the details of the show refered to by ID.
|
||||
///
|
||||
/// # Errors
|
||||
/// See [Error] for failure modes.
|
||||
#[inline]
|
||||
#[expect(clippy::impl_trait_in_params, reason = "turbofish is not expected")]
|
||||
pub async fn get_details(
|
||||
&self,
|
||||
id: impl Into<ShowId>,
|
||||
|
||||
+35
-22
@@ -1,58 +1,72 @@
|
||||
//! Caching related types.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bytes::Bytes;
|
||||
use redb::{Database, DatabaseError, ReadableDatabase, TableDefinition};
|
||||
use redb::{Database, DatabaseError, ReadableDatabase as _, TableDefinition};
|
||||
|
||||
/// The client cache policy
|
||||
/// The client cache policy.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[non_exhaustive]
|
||||
pub enum CachePolicy {
|
||||
/// Do not use a cache
|
||||
/// Do not use a cache.
|
||||
None,
|
||||
/// Use and update the cache
|
||||
/// Use and update the cache.
|
||||
Full,
|
||||
/// Use the cache but don't update it
|
||||
/// Use the cache but don't update it.
|
||||
Read,
|
||||
/// Ignore the cache but update it
|
||||
/// Ignore the cache but update it.
|
||||
Update,
|
||||
}
|
||||
|
||||
/// The trait representing a caching backend
|
||||
pub trait Cache {
|
||||
/// Get a cached value, or None
|
||||
/// The trait representing a caching backend.
|
||||
pub trait Cache: core::fmt::Debug + Send + Sync {
|
||||
/// Get a cached value, or None.
|
||||
fn get(&self, query: &str) -> Option<Bytes>;
|
||||
/// Set a value in the cache
|
||||
/// Set a value in the cache.
|
||||
fn set(&self, query: &str, response: &Bytes);
|
||||
}
|
||||
|
||||
const TABLE: TableDefinition<&str, (u64, &[u8])> = TableDefinition::new("tmdb_responses");
|
||||
/// The [`TableDefinition`] for TMDB response data.
|
||||
const TABLE: TableDefinition<'_, &str, (u64, &[u8])> = TableDefinition::new("tmdb_responses");
|
||||
|
||||
/// A [Cache] implementation using [redb] as the backend
|
||||
/// A [Cache] implementation using [redb] as the backend.
|
||||
#[derive(Debug)]
|
||||
pub struct RedbCache {
|
||||
/// The underlying database.
|
||||
db: Database,
|
||||
}
|
||||
|
||||
impl RedbCache {
|
||||
/// Create/open a [redb] database at the path
|
||||
/// Create/open a [redb] database at the path.
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails if creating/opening the database returns an error.
|
||||
#[inline]
|
||||
pub fn new(path: &Path) -> Result<Self, DatabaseError> {
|
||||
Ok(Self {
|
||||
db: Database::create(path)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Helper function allowing for `.ok()?`
|
||||
/// Helper function allowing for `.ok()?`.
|
||||
fn write(&self, timestamp: u64, query: &str, response: &Bytes) -> Option<()> {
|
||||
let write_txn = self.db.begin_write().ok()?;
|
||||
{
|
||||
let mut table = write_txn.open_table(TABLE).ok()?;
|
||||
table
|
||||
.insert(query, (timestamp, response.iter().as_slice()))
|
||||
.ok()?;
|
||||
drop(
|
||||
table
|
||||
.insert(query, (timestamp, response.iter().as_slice()))
|
||||
.ok()?,
|
||||
);
|
||||
}
|
||||
write_txn.commit().ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl Cache for RedbCache {
|
||||
#[inline]
|
||||
fn get(&self, query: &str) -> Option<Bytes> {
|
||||
let read_txn = self.db.begin_read().ok()?;
|
||||
let table = read_txn.open_table(TABLE).ok()?;
|
||||
@@ -62,8 +76,7 @@ impl Cache for RedbCache {
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
.map_or(0, |d| d.as_secs());
|
||||
|
||||
if now.saturating_sub(timestamp) >= 60 * 60 * 24 * 30 * 6 {
|
||||
None
|
||||
@@ -72,12 +85,12 @@ impl Cache for RedbCache {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn set(&self, query: &str, response: &Bytes) {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
.map_or(0, |d| d.as_secs());
|
||||
|
||||
self.write(now, query, response);
|
||||
_ = self.write(now, query, response);
|
||||
}
|
||||
}
|
||||
|
||||
+57
-30
@@ -1,44 +1,63 @@
|
||||
use std::rc::Rc;
|
||||
//! TMDB client.
|
||||
|
||||
use alloc::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::{Cache, CachePolicy, Config, api};
|
||||
|
||||
/// The primary client that references all other clients
|
||||
/// The primary client that references all other clients.
|
||||
#[derive(Debug)]
|
||||
pub struct Client {
|
||||
/// The collection API client.
|
||||
collections: api::collections::Client,
|
||||
/// The movie API client.
|
||||
movies: api::movies::Client,
|
||||
/// The show API client.
|
||||
shows: api::shows::Client,
|
||||
/// The season API client.
|
||||
seasons: api::seasons::Client,
|
||||
/// The episode API client.
|
||||
episodes: api::episodes::Client,
|
||||
|
||||
cache_policy: Rc<RwLock<CachePolicy>>,
|
||||
/// The cache policy to use for all clients.
|
||||
cache_policy: Arc<RwLock<CachePolicy>>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Create a new client with the given configuration
|
||||
pub fn new(config: Config, cache: Rc<dyn Cache>, cache_policy: CachePolicy) -> Self {
|
||||
let config = Rc::new(config);
|
||||
let cache_policy = Rc::new(RwLock::new(cache_policy));
|
||||
/// Create a new client with the given configuration.
|
||||
#[inline]
|
||||
pub fn new(config: Config, cache: Arc<dyn Cache>, cache_policy: CachePolicy) -> Self {
|
||||
let config = Arc::new(config);
|
||||
let cache_policy = Arc::new(RwLock::new(cache_policy));
|
||||
Self {
|
||||
collections: api::collections::Client::new(
|
||||
config.clone(),
|
||||
cache.clone(),
|
||||
cache_policy.clone(),
|
||||
Arc::clone(&config),
|
||||
Arc::clone(&cache),
|
||||
Arc::clone(&cache_policy),
|
||||
),
|
||||
movies: api::movies::Client::new(config.clone(), cache.clone(), cache_policy.clone()),
|
||||
shows: api::shows::Client::new(config.clone(), cache.clone(), cache_policy.clone()),
|
||||
seasons: api::seasons::Client::new(config.clone(), cache.clone(), cache_policy.clone()),
|
||||
episodes: api::episodes::Client::new(
|
||||
config.clone(),
|
||||
cache.clone(),
|
||||
cache_policy.clone(),
|
||||
movies: api::movies::Client::new(
|
||||
Arc::clone(&config),
|
||||
Arc::clone(&cache),
|
||||
Arc::clone(&cache_policy),
|
||||
),
|
||||
shows: api::shows::Client::new(
|
||||
Arc::clone(&config),
|
||||
Arc::clone(&cache),
|
||||
Arc::clone(&cache_policy),
|
||||
),
|
||||
seasons: api::seasons::Client::new(
|
||||
Arc::clone(&config),
|
||||
Arc::clone(&cache),
|
||||
Arc::clone(&cache_policy),
|
||||
),
|
||||
episodes: api::episodes::Client::new(config, cache, Arc::clone(&cache_policy)),
|
||||
|
||||
cache_policy,
|
||||
}
|
||||
}
|
||||
|
||||
/// Modify the [CachePolicy]
|
||||
/// Modify the [`CachePolicy`].
|
||||
#[inline]
|
||||
pub fn set_cache_policy(&self, new_policy: CachePolicy) {
|
||||
match self.cache_policy.write() {
|
||||
Ok(mut policy) => *policy = new_policy,
|
||||
@@ -48,31 +67,39 @@ impl Client {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Access the Collections API
|
||||
pub fn collections(&self) -> &api::collections::Client {
|
||||
/// Access the Collections API.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub const fn collections(&self) -> &api::collections::Client {
|
||||
&self.collections
|
||||
}
|
||||
|
||||
/// Access the Movies API
|
||||
pub fn movies(&self) -> &api::movies::Client {
|
||||
/// Access the Movies API.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub const fn movies(&self) -> &api::movies::Client {
|
||||
&self.movies
|
||||
}
|
||||
|
||||
/// Access the Shows API
|
||||
pub fn shows(&self) -> &api::shows::Client {
|
||||
/// Access the Shows API.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub const fn shows(&self) -> &api::shows::Client {
|
||||
&self.shows
|
||||
}
|
||||
|
||||
/// Access the Seasons API
|
||||
pub fn seasons(&self) -> &api::seasons::Client {
|
||||
/// Access the Seasons API.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub const fn seasons(&self) -> &api::seasons::Client {
|
||||
&self.seasons
|
||||
}
|
||||
|
||||
/// Access the Episodes API
|
||||
pub fn episodes(&self) -> &api::episodes::Client {
|
||||
/// Access the Episodes API.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub const fn episodes(&self) -> &api::episodes::Client {
|
||||
&self.episodes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Client configuration.
|
||||
|
||||
use governor::clock::MonotonicClock;
|
||||
use governor::state::{InMemoryState, NotKeyed};
|
||||
use governor::{Quota, RateLimiter};
|
||||
@@ -5,27 +7,31 @@ use nonzero_ext::nonzero;
|
||||
use url::Url;
|
||||
use url_macro::url;
|
||||
|
||||
/// The client configuration
|
||||
/// The client configuration.
|
||||
#[derive(Debug)]
|
||||
#[expect(clippy::exhaustive_structs, reason = "must be constructed in full")]
|
||||
pub struct Config {
|
||||
/// The base URL of the API
|
||||
/// The base URL of the API.
|
||||
pub base_url: Url,
|
||||
/// The reqwest client that is used for every request
|
||||
/// The reqwest client that is used for every request.
|
||||
pub client: reqwest::Client,
|
||||
/// The rate limiter to use for the client
|
||||
/// The rate limiter to use for the client.
|
||||
pub limiter: RateLimiter<NotKeyed, InMemoryState, MonotonicClock>,
|
||||
/// The bearer token for readonly access to the API
|
||||
/// The bearer token for readonly access to the API.
|
||||
pub bearer_token: String,
|
||||
/// An optional user agent string to provide to the API
|
||||
/// An optional user agent string to provide to the API.
|
||||
pub user_agent: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Create a new configuration using the provided bearer token
|
||||
/// Create a new configuration using the provided bearer token.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn new(bearer_token: String) -> Self {
|
||||
Self {
|
||||
base_url: url!("https://api.themoviedb.org"),
|
||||
client: reqwest::Client::new(),
|
||||
limiter: RateLimiter::direct(Quota::per_second(nonzero!(30u32))),
|
||||
limiter: RateLimiter::direct(Quota::per_second(nonzero!(30_u32))),
|
||||
bearer_token,
|
||||
user_agent: None,
|
||||
}
|
||||
|
||||
+20
-1
@@ -1,7 +1,9 @@
|
||||
//! flix-tmdb provides clients and models for fetching data from TMDB
|
||||
//! flix-tmdb provides clients and models for fetching data from TMDB.
|
||||
|
||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
|
||||
extern crate alloc;
|
||||
|
||||
pub mod api;
|
||||
pub mod model;
|
||||
|
||||
@@ -13,3 +15,20 @@ pub use client::Client;
|
||||
|
||||
mod config;
|
||||
pub use config::Config;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use alloc::sync::Arc;
|
||||
|
||||
use super::{CachePolicy, Client, Config, RedbCache};
|
||||
|
||||
#[cfg(not(miri))]
|
||||
#[test]
|
||||
#[expect(clippy::missing_panics_doc, reason = "unit test")]
|
||||
fn use_types() {
|
||||
let tmp = tempfile::tempdir().expect("temporary directory exists");
|
||||
let config = Config::new(String::new());
|
||||
let cache = Arc::new(RedbCache::new(&tmp.path().join("cache.redb")).expect("redb"));
|
||||
drop(Client::new(config, cache, CachePolicy::Full));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,48 @@
|
||||
//! TMDB collection model.
|
||||
|
||||
use super::id::{CollectionId, MovieId};
|
||||
|
||||
/// A deserialized Collection from the TMDB API
|
||||
/// A deserialized Collection from the TMDB API.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct Collection {
|
||||
/// The collection's TMDB ID
|
||||
/// The collection's TMDB ID.
|
||||
pub id: CollectionId,
|
||||
/// The collection's title
|
||||
/// The collection's title.
|
||||
#[serde(rename = "name")]
|
||||
pub title: String,
|
||||
/// The collection's overview
|
||||
/// The collection's overview.
|
||||
pub overview: String,
|
||||
/// The list of movies that are part of this collection
|
||||
/// The list of movies that are part of this collection.
|
||||
#[serde(rename = "parts")]
|
||||
pub movies: Vec<Item>,
|
||||
}
|
||||
|
||||
/// A deserialized collection item from the TMDB API
|
||||
/// A deserialized collection item from the TMDB API.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct Item {
|
||||
/// The movie's TMDB ID
|
||||
/// The movie's TMDB ID.
|
||||
pub id: MovieId,
|
||||
/// The movie's title
|
||||
/// The movie's title.
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Collection, CollectionId, Item, MovieId};
|
||||
|
||||
#[test]
|
||||
fn use_types() {
|
||||
drop(Collection {
|
||||
id: CollectionId::from_raw(0),
|
||||
title: String::new(),
|
||||
overview: String::new(),
|
||||
movies: Vec::new(),
|
||||
});
|
||||
drop(Item {
|
||||
id: MovieId::from_raw(0),
|
||||
title: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! TMDB episode model.
|
||||
|
||||
use core::time::Duration;
|
||||
|
||||
use flix_model::numbers::EpisodeNumber;
|
||||
@@ -7,22 +9,44 @@ use url::Url;
|
||||
|
||||
use super::{duration_from_minutes, still_url_from_path};
|
||||
|
||||
/// A deserialized Episode from the TMDB API
|
||||
/// A deserialized Episode from the TMDB API.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct Episode {
|
||||
/// The episode's number
|
||||
/// The episode's number.
|
||||
pub episode_number: EpisodeNumber,
|
||||
/// The episode's title
|
||||
/// The episode's title.
|
||||
#[serde(rename = "name")]
|
||||
pub title: String,
|
||||
/// The episode's overview
|
||||
/// The episode's overview.
|
||||
pub overview: String,
|
||||
/// The episode's air date
|
||||
/// The episode's air date.
|
||||
pub air_date: NaiveDate,
|
||||
/// The episode's runtime
|
||||
/// The episode's runtime.
|
||||
#[serde(deserialize_with = "duration_from_minutes")]
|
||||
pub runtime: Duration,
|
||||
/// The episode's still path
|
||||
/// The episode's still path.
|
||||
#[serde(deserialize_with = "still_url_from_path")]
|
||||
pub still_path: Option<Url>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use core::time::Duration;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use super::{Episode, EpisodeNumber};
|
||||
|
||||
#[test]
|
||||
fn use_types() {
|
||||
drop(Episode {
|
||||
episode_number: EpisodeNumber::new(0),
|
||||
title: String::new(),
|
||||
overview: String::new(),
|
||||
air_date: NaiveDate::default(),
|
||||
runtime: Duration::default(),
|
||||
still_path: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+63
-24
@@ -1,4 +1,6 @@
|
||||
//! Typed TMDB IDs
|
||||
//! Typed TMDB IDs.
|
||||
|
||||
#![expect(clippy::module_name_repetitions, reason = "ID types end with Id")]
|
||||
|
||||
use core::cmp::Ordering;
|
||||
use core::fmt;
|
||||
@@ -10,21 +12,25 @@ use sea_orm::sea_query::{ArrayType, Nullable, ValueType, ValueTypeErr};
|
||||
#[cfg(feature = "sea-orm")]
|
||||
use sea_orm::{ColIdx, ColumnType, DbErr, QueryResult, TryFromU64, TryGetError, TryGetable, Value};
|
||||
|
||||
/// The internal representation used by TMDB
|
||||
/// The internal representation used by TMDB.
|
||||
pub type TmdbRepr = u32;
|
||||
|
||||
/// An opaque type representing a TMDB ID
|
||||
/// An opaque type representing a TMDB ID.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(transparent)]
|
||||
#[repr(transparent)]
|
||||
pub struct Id<T> {
|
||||
/// Internal ID representation.
|
||||
id: TmdbRepr,
|
||||
/// Marker to indicate an invariant T.
|
||||
#[serde(skip_serializing, default)]
|
||||
_phantom: PhantomData<T>,
|
||||
_phantom: PhantomData<fn(T) -> T>,
|
||||
}
|
||||
|
||||
// Manual implementation since `T: Clone` is not required
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl<T> Clone for Id<T> {
|
||||
#[inline]
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
@@ -34,31 +40,40 @@ impl<T> Clone for Id<T> {
|
||||
impl<T> Copy for Id<T> {}
|
||||
|
||||
// Manual implementation since `T: PartialEq` is not required
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl<T> PartialEq for Id<T> {
|
||||
#[inline]
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
// Manual implementation since `T: Eq` is not required
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl<T> Eq for Id<T> {}
|
||||
|
||||
// Manual implementation since `T: PartialOrd` is not required
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl<T> PartialOrd for Id<T> {
|
||||
#[inline]
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
// Manual implementation since `T: Ord` is not required
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl<T> Ord for Id<T> {
|
||||
#[inline]
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.id.cmp(&other.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Manual implementation since `T: Hash` is not required
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl<T> Hash for Id<T> {
|
||||
#[inline]
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.id.hash(state);
|
||||
}
|
||||
@@ -66,6 +81,8 @@ impl<T> Hash for Id<T> {
|
||||
|
||||
impl<T> Id<T> {
|
||||
/// Allows the conversion from a raw value to [Id], though the use is discouraged.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn from_raw(raw: TmdbRepr) -> Self {
|
||||
Self {
|
||||
id: raw,
|
||||
@@ -74,13 +91,16 @@ impl<T> Id<T> {
|
||||
}
|
||||
|
||||
/// Allows extracting the raw value, though the use is discouraged.
|
||||
pub fn into_raw(self) -> TmdbRepr {
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub const fn into_raw(self) -> TmdbRepr {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> fmt::Debug for Id<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
#[inline]
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Id")
|
||||
.field("T", &core::any::type_name::<T>())
|
||||
.field("id", &self.id)
|
||||
@@ -89,7 +109,9 @@ impl<T> fmt::Debug for Id<T> {
|
||||
}
|
||||
|
||||
#[cfg(feature = "sea-orm")]
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl<T> ValueType for Id<T> {
|
||||
#[inline]
|
||||
fn try_from(v: Value) -> Result<Self, ValueTypeErr> {
|
||||
<TmdbRepr as ValueType>::try_from(v).map(|id| Self {
|
||||
id,
|
||||
@@ -97,14 +119,17 @@ impl<T> ValueType for Id<T> {
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn type_name() -> String {
|
||||
format!("Id<{}>", &core::any::type_name::<T>())
|
||||
format!("Id<{}>", core::any::type_name::<T>())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn array_type() -> ArrayType {
|
||||
TmdbRepr::array_type()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn column_type() -> ColumnType {
|
||||
TmdbRepr::column_type()
|
||||
}
|
||||
@@ -112,13 +137,16 @@ impl<T> ValueType for Id<T> {
|
||||
|
||||
#[cfg(feature = "sea-orm")]
|
||||
impl<T> From<Id<T>> for Value {
|
||||
#[inline]
|
||||
fn from(value: Id<T>) -> Self {
|
||||
value.id.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sea-orm")]
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl<T> TryGetable for Id<T> {
|
||||
#[inline]
|
||||
fn try_get_by<I: ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
|
||||
TmdbRepr::try_get_by(res, index).map(|id| Self {
|
||||
id,
|
||||
@@ -129,6 +157,7 @@ impl<T> TryGetable for Id<T> {
|
||||
|
||||
#[cfg(feature = "sea-orm")]
|
||||
impl<T> TryFromU64 for Id<T> {
|
||||
#[inline]
|
||||
fn try_from_u64(n: u64) -> Result<Self, DbErr> {
|
||||
TmdbRepr::try_from_u64(n).map(|id| Self {
|
||||
id,
|
||||
@@ -139,6 +168,7 @@ impl<T> TryFromU64 for Id<T> {
|
||||
|
||||
#[cfg(feature = "sea-orm")]
|
||||
impl<T> Nullable for Id<T> {
|
||||
#[inline]
|
||||
fn null() -> Value {
|
||||
TmdbRepr::null()
|
||||
}
|
||||
@@ -146,34 +176,34 @@ impl<T> Nullable for Id<T> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
#[cfg(feature = "sea-orm")]
|
||||
fn test_sea_orm() {
|
||||
#[expect(dead_code, reason = "structs test derive macros")]
|
||||
mod test_seaorm {
|
||||
use sea_orm::{
|
||||
ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait,
|
||||
EnumIter, PrimaryKeyTrait,
|
||||
};
|
||||
|
||||
use super::Id;
|
||||
use super::super::Id;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "ids")]
|
||||
pub struct Model {
|
||||
#[expect(clippy::use_self, reason = "derive macros cause Self to be invalid")]
|
||||
struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
id: Id<Model>,
|
||||
nullable: Option<Id<Model>>,
|
||||
}
|
||||
|
||||
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
enum Relation {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde() {
|
||||
fn serde() {
|
||||
use super::Id;
|
||||
|
||||
let id: Id<()> = Id::from_raw(1234);
|
||||
@@ -181,61 +211,70 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type alias for the raw ID representation
|
||||
pub use self::TmdbRepr as RawId;
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[non_exhaustive]
|
||||
pub enum Collection {}
|
||||
/// Type alias for a collection ID
|
||||
/// Type alias for a collection ID.
|
||||
pub type CollectionId = Id<Collection>;
|
||||
|
||||
impl From<CollectionId> for flix_model::id::CollectionId {
|
||||
#[inline]
|
||||
fn from(value: CollectionId) -> Self {
|
||||
Self::from_raw(value.into_raw().into())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<flix_model::id::CollectionId> for CollectionId {
|
||||
type Error = <RawId as TryFrom<flix_model::id::RawId>>::Error;
|
||||
type Error = <TmdbRepr as TryFrom<flix_model::id::RawId>>::Error;
|
||||
|
||||
#[inline]
|
||||
fn try_from(value: flix_model::id::CollectionId) -> Result<Self, Self::Error> {
|
||||
value.into_raw().try_into().map(Self::from_raw)
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[non_exhaustive]
|
||||
pub enum Movie {}
|
||||
/// Type alias for a movie ID
|
||||
/// Type alias for a movie ID.
|
||||
pub type MovieId = Id<Movie>;
|
||||
|
||||
impl From<MovieId> for flix_model::id::MovieId {
|
||||
#[inline]
|
||||
fn from(value: MovieId) -> Self {
|
||||
Self::from_raw(value.into_raw().into())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<flix_model::id::MovieId> for MovieId {
|
||||
type Error = <RawId as TryFrom<flix_model::id::RawId>>::Error;
|
||||
type Error = <TmdbRepr as TryFrom<flix_model::id::RawId>>::Error;
|
||||
|
||||
#[inline]
|
||||
fn try_from(value: flix_model::id::MovieId) -> Result<Self, Self::Error> {
|
||||
value.into_raw().try_into().map(Self::from_raw)
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[non_exhaustive]
|
||||
pub enum Show {}
|
||||
/// Type alias for a show ID
|
||||
/// Type alias for a show ID.
|
||||
pub type ShowId = Id<Show>;
|
||||
|
||||
impl From<ShowId> for flix_model::id::ShowId {
|
||||
#[inline]
|
||||
fn from(value: ShowId) -> Self {
|
||||
Self::from_raw(value.into_raw().into())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<flix_model::id::ShowId> for ShowId {
|
||||
type Error = <RawId as TryFrom<flix_model::id::RawId>>::Error;
|
||||
type Error = <TmdbRepr as TryFrom<flix_model::id::RawId>>::Error;
|
||||
|
||||
#[inline]
|
||||
fn try_from(value: flix_model::id::ShowId) -> Result<Self, Self::Error> {
|
||||
value.into_raw().try_into().map(Self::from_raw)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Deserializable types from the TMDB API
|
||||
//! Deserializable types from the TMDB API.
|
||||
|
||||
use core::str::FromStr;
|
||||
use core::str::FromStr as _;
|
||||
use core::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use serde::{Deserialize as _, Deserializer};
|
||||
use url::Url;
|
||||
|
||||
pub mod id;
|
||||
@@ -20,14 +20,22 @@ pub use movie::*;
|
||||
pub use season::*;
|
||||
pub use show::*;
|
||||
|
||||
/// Deserializer for converting integer minutes to a [Duration].
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails if the value being deserialized isn't a [u64].
|
||||
fn duration_from_minutes<'de, D>(deserializer: D) -> Result<Duration, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let minutes = u64::deserialize(deserializer).unwrap_or(0);
|
||||
let minutes = u64::deserialize(deserializer)?;
|
||||
Ok(Duration::from_secs(minutes.saturating_mul(60)))
|
||||
}
|
||||
|
||||
/// Deserializer for converting a string to a [Url].
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails if the value being deserialized isn't a [`&str`].
|
||||
fn still_url_from_path<'de, D>(deserializer: D) -> Result<Option<Url>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
@@ -38,10 +46,6 @@ where
|
||||
let path = Option::<&str>::deserialize(deserializer)?;
|
||||
|
||||
Ok(path.and_then(|path| {
|
||||
Url::from_str(&format!(
|
||||
"{}{}{}",
|
||||
TMDB_IMAGE_BASE, TMDB_IMAGE_QUALITY, path
|
||||
))
|
||||
.ok()
|
||||
Url::from_str(&format!("{TMDB_IMAGE_BASE}{TMDB_IMAGE_QUALITY}{path}")).ok()
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! TMDB movie model.
|
||||
|
||||
use core::time::Duration;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
@@ -5,33 +7,35 @@ use chrono::NaiveDate;
|
||||
use super::duration_from_minutes;
|
||||
use super::id::{CollectionId, MovieId};
|
||||
|
||||
/// A deserialized Movie from the TMDB API
|
||||
/// A deserialized Movie from the TMDB API.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct Movie {
|
||||
/// The movie's TMDB ID
|
||||
/// The movie's TMDB ID.
|
||||
pub id: MovieId,
|
||||
/// The movie's collection, if it exists
|
||||
/// The movie's collection, if it exists.
|
||||
#[serde(rename = "belongs_to_collection")]
|
||||
pub collection: Option<InCollection>,
|
||||
/// The movie's title
|
||||
/// The movie's title.
|
||||
pub title: String,
|
||||
/// The movie's tagline
|
||||
/// The movie's tagline.
|
||||
pub tagline: String,
|
||||
/// The movie's overview
|
||||
/// The movie's overview.
|
||||
pub overview: String,
|
||||
/// The movie's release date
|
||||
/// The movie's release date.
|
||||
pub release_date: NaiveDate,
|
||||
/// The movie's runtime
|
||||
/// The movie's runtime.
|
||||
#[serde(deserialize_with = "duration_from_minutes")]
|
||||
pub runtime: Duration,
|
||||
}
|
||||
|
||||
/// A deserialized movie's collection from the TMDB API
|
||||
/// A deserialized movie's collection from the TMDB API.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct InCollection {
|
||||
/// The collection's TMDB ID
|
||||
/// The collection's TMDB ID.
|
||||
pub id: CollectionId,
|
||||
/// The collection's title
|
||||
/// The collection's title.
|
||||
#[serde(rename = "name")]
|
||||
pub title: String,
|
||||
}
|
||||
@@ -43,3 +47,28 @@ pub struct InCollection {
|
||||
// TODO: Company
|
||||
// pub companies: Vec<Company>
|
||||
// where: struct Company { id, name }
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use core::time::Duration;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use super::{CollectionId, InCollection, Movie, MovieId};
|
||||
|
||||
#[test]
|
||||
fn use_types() {
|
||||
drop(Movie {
|
||||
id: MovieId::from_raw(0),
|
||||
collection: Some(InCollection {
|
||||
id: CollectionId::from_raw(0),
|
||||
title: String::new(),
|
||||
}),
|
||||
title: String::new(),
|
||||
tagline: String::new(),
|
||||
overview: String::new(),
|
||||
release_date: NaiveDate::default(),
|
||||
runtime: Duration::default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,46 @@
|
||||
//! TMDB season model.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use flix_model::numbers::SeasonNumber;
|
||||
|
||||
/// A deserialized Season from the TMDB API
|
||||
/// A deserialized Season from the TMDB API.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct Season {
|
||||
/// The season's number
|
||||
/// The season's number.
|
||||
pub season_number: SeasonNumber,
|
||||
/// The season's title
|
||||
/// The season's title.
|
||||
#[serde(rename = "name")]
|
||||
pub title: String,
|
||||
/// The season's overview
|
||||
/// The season's overview.
|
||||
pub overview: String,
|
||||
/// The season's air date
|
||||
/// The season's air date.
|
||||
pub air_date: NaiveDate,
|
||||
/// The number of episodes in this season
|
||||
/// The number of episodes in this season.
|
||||
pub episodes: Vec<FakeEpisode>,
|
||||
}
|
||||
|
||||
/// A placeholder struct for parsing the episodes list for a season
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
/// A placeholder struct for parsing the episodes list for a season.
|
||||
#[derive(Debug, Clone, Copy, serde::Deserialize)]
|
||||
#[non_exhaustive]
|
||||
#[expect(clippy::empty_structs_with_brackets, reason = "might add fields later")]
|
||||
pub struct FakeEpisode {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use super::{Season, SeasonNumber};
|
||||
|
||||
#[test]
|
||||
fn use_types() {
|
||||
drop(Season {
|
||||
season_number: SeasonNumber::new(0),
|
||||
title: String::new(),
|
||||
overview: String::new(),
|
||||
air_date: NaiveDate::default(),
|
||||
episodes: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
//! TMDB show model.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use super::id::ShowId;
|
||||
|
||||
/// A deserialized Show from the TMDB API
|
||||
/// A deserialized Show from the TMDB API.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct Show {
|
||||
/// The show's TMDB ID
|
||||
/// The show's TMDB ID.
|
||||
pub id: ShowId,
|
||||
/// The show's title
|
||||
/// The show's title.
|
||||
#[serde(rename = "name")]
|
||||
pub title: String,
|
||||
/// The show's tagline
|
||||
/// The show's tagline.
|
||||
pub tagline: String,
|
||||
/// The show's overview
|
||||
/// The show's overview.
|
||||
pub overview: String,
|
||||
/// The show's first air date
|
||||
/// The show's first air date.
|
||||
pub first_air_date: NaiveDate,
|
||||
/// The show's last air date
|
||||
/// The show's last air date.
|
||||
pub last_air_date: NaiveDate,
|
||||
/// The total number of episodes in this show
|
||||
/// The total number of episodes in this show.
|
||||
pub number_of_episodes: u32,
|
||||
/// The number of seasons in this show
|
||||
/// The number of seasons in this show.
|
||||
pub number_of_seasons: u32,
|
||||
}
|
||||
|
||||
@@ -35,3 +38,24 @@ pub struct Show {
|
||||
// TODO: Company
|
||||
// pub companies: Vec<Company>
|
||||
// where: struct Company { id, name }
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use super::{Show, ShowId};
|
||||
|
||||
#[test]
|
||||
fn use_types() {
|
||||
drop(Show {
|
||||
id: ShowId::from_raw(0),
|
||||
title: String::new(),
|
||||
tagline: String::new(),
|
||||
overview: String::new(),
|
||||
first_air_date: NaiveDate::default(),
|
||||
last_air_date: NaiveDate::default(),
|
||||
number_of_episodes: 0,
|
||||
number_of_seasons: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user