9 Commits

59 changed files with 7522 additions and 1551 deletions
+4
View File
@@ -5,3 +5,7 @@
# Rust # Rust
/target /target
# Flix
flix.db
flix.redb
-9
View File
@@ -1,9 +0,0 @@
{
"recommendations": [
"vadimcn.vscode-lldb",
"barbosshack.crates-io",
"usernamehw.errorlens",
"tamasfe.even-better-toml",
"rust-lang.rust-analyzer",
]
}
-33
View File
@@ -1,33 +0,0 @@
{
// VSCode
"editor.detectIndentation": false,
"editor.insertSpaces": false,
"editor.tabSize": 4,
"files.exclude": {
"**/target": true,
"**/Cargo.lock": true,
},
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true,
"files.trimTrailingWhitespace": true,
"files.watcherExclude": {
"**/.git/**": true,
"**/target/**": true,
},
// Extensions
"crates.listPreReleases": true,
"evenBetterToml.formatter.alignComments": true,
"evenBetterToml.formatter.alignEntries": false,
"evenBetterToml.formatter.allowedBlankLines": 1,
"evenBetterToml.formatter.arrayAutoExpand": true,
"evenBetterToml.formatter.arrayTrailingComma": true,
"evenBetterToml.formatter.columnWidth": 80,
"evenBetterToml.formatter.reorderKeys": true,
"evenBetterToml.formatter.trailingNewline": true,
"rust-analyzer.imports.granularity.enforce": true,
"rust-analyzer.imports.granularity.group": "module",
"rust-analyzer.imports.group.enable": true,
"rust-analyzer.imports.merge.glob": false,
"rust-analyzer.imports.preferNoStd": true,
"rust-analyzer.showUnlinkedFileNotification": false,
}
+25
View File
@@ -0,0 +1,25 @@
{
"languages": {
"TOML": {
"format_on_save": "on",
"formatter": { "language_server": { "name": "tombi" } },
},
},
"lsp": {
"rust-analyzer": {
"initialization_options": {
"imports": {
"granularity": { "enforce": true, "group": "module" },
"group": { "enable": true },
"merge": { "glob": false },
"preferNoStd": true,
},
"server": {
"extraEnv": {
"RUSTUP_TOOLCHAIN": "stable",
},
},
},
},
},
}
+2 -1
View File
@@ -3,4 +3,5 @@ How to Contribute
We'd love to accept your patches and contributions to this project. We'd love to accept your patches and contributions to this project.
We just need you to follow the Contributor License Agreement outlined We just need you to follow the Contributor License Agreement outlined
in the latest v0.0.x of https://github.com/Skrunix/license in the latest v0.0.x of https://git.skrundz.dev/skrunix/license
(mirrored to https://github.com/skrunix/license)
Generated
+1471 -386
View File
File diff suppressed because it is too large Load Diff
+56 -46
View File
@@ -1,18 +1,55 @@
[workspace] [workspace]
members = ["crates/*"]
resolver = "2" resolver = "2"
members = ["crates/*"]
[workspace.package] [workspace.package]
authors = []
edition = "2024"
license-file = "LICENSE.md" license-file = "LICENSE.md"
rust-version = "1.85.0"
[workspace.lints.rust] edition = "2024"
arithmetic_overflow = "forbid" rust-version = "1.89.0"
missing_docs = "forbid"
unsafe_code = "forbid" [workspace.dependencies]
unused_doc_comments = "forbid" flix = { path = "crates/flix", version = "=0.0.19", default-features = false }
flix-cli = { path = "crates/cli", version = "=0.0.19", default-features = false }
flix-db = { path = "crates/db", version = "=0.0.19", default-features = false }
flix-fs = { path = "crates/fs", version = "=0.0.19", default-features = false }
flix-model = { path = "crates/model", version = "=0.0.19", default-features = false }
flix-mux = { path = "crates/cli-mux", version = "=0.0.19", default-features = false }
flix-tmdb = { path = "crates/tmdb", version = "=0.0.19", default-features = false }
seamantic = { version = "^0.0.14", default-features = false }
sea-orm = { version = "=2.0.0-rc.38", default-features = false }
sea-orm-migration = { version = "=2.0.0-rc.38", default-features = false }
anyhow = { version = "^1", default-features = false }
async-stream = { version = "^0.3", default-features = false }
bytes = { version = "^1", default-features = false }
chrono = { version = "^0.4", default-features = false }
clap = { version = "^4", default-features = false }
console = { version = "^0.16", default-features = false }
dialoguer = { version = "^0.12", default-features = false }
either = { version = "^1", default-features = false }
futures = { version = "^0.3", default-features = false }
governor = { version = "^0.10", default-features = false }
indicatif = { version = "^0.18", default-features = false }
itertools = { version = "^0.14", default-features = false }
nonzero_ext = { version = "^0.3", default-features = false }
redb = { version = "^4", default-features = false }
regex = { version = "^1", default-features = false }
reqwest = { version = "^0.13", default-features = false }
serde = { version = "^1", default-features = false }
serde_json = { version = "^1", default-features = false }
serde_test = { version = "^1", default-features = false }
thiserror = { version = "^2", default-features = false }
tokio = { version = "^1", default-features = false }
tokio-stream = { version = "^0.1", default-features = false }
toml = { version = "^1", default-features = false }
tracing = { version = "^0.1", default-features = false }
tracing-subscriber = { version = "^0.3", default-features = false }
url = { version = "^2", default-features = false }
url-macro = { version = "^0.2", default-features = false }
walkdir = { version = "^2", default-features = false }
[workspace.lints.clippy] [workspace.lints.clippy]
arithmetic_side_effects = "forbid" arithmetic_side_effects = "forbid"
@@ -24,45 +61,18 @@ indexing_slicing = "forbid"
integer_division = "forbid" integer_division = "forbid"
integer_division_remainder_used = "forbid" integer_division_remainder_used = "forbid"
transmute_undefined_repr = "forbid" transmute_undefined_repr = "forbid"
unchecked_duration_subtraction = "forbid" unchecked_time_subtraction = "forbid"
unwrap_used = "forbid" unwrap_used = "forbid"
[workspace.lints.rust]
arithmetic_overflow = "forbid"
missing_docs = "forbid"
unsafe_code = "forbid"
unused_doc_comments = "forbid"
[profile.release] [profile.release]
codegen-units = 1
lto = "fat"
opt-level = 3 opt-level = 3
overflow-checks = true
strip = "debuginfo" strip = "debuginfo"
overflow-checks = true
[workspace.dependencies] lto = "fat"
flix = { path = "crates/flix", version = "=0.0.13", default-features = false } codegen-units = 1
flix-cli = { path = "crates/cli", version = "=0.0.13", default-features = false }
flix-db = { path = "crates/db", version = "=0.0.13", default-features = false }
flix-fs = { path = "crates/fs", version = "=0.0.13", default-features = false }
flix-model = { path = "crates/model", version = "=0.0.13", default-features = false }
flix-tmdb = { path = "crates/tmdb", version = "=0.0.13", default-features = false }
seamantic = { version = "0.0.10", default-features = false }
sea-orm = { version = "2.0.0-rc.17", default-features = false }
sea-orm-migration = { version = "2.0.0-rc.17", default-features = false }
anyhow = { version = "^1", default-features = false }
async-stream = { version = "^0.3", default-features = false }
chrono = { version = "^0.4", default-features = false }
clap = { version = "^4", default-features = false, features = ["std"] }
futures = { version = "^0.3", default-features = false }
governor = { version = "^0.10", default-features = false }
nonzero_ext = { version = "^0.3", default-features = false }
regex = { version = "^1", default-features = false }
reqwest = { version = "^0.12", default-features = false }
serde = { version = "^1", default-features = false }
serde_test = { version = "^1", default-features = false }
thiserror = { version = "^2", default-features = false }
tokio = { version = "^1", default-features = false }
tokio-stream = { version = "^0.1", default-features = false }
toml = { version = "^0.9", default-features = false }
tracing = { version = "^0.1", default-features = false }
tracing-subscriber = { version = "^0.3", default-features = false }
url = { version = "^2", default-features = false }
url-macro = { version = "^0.2", default-features = false }
+6 -1
View File
@@ -7,9 +7,14 @@ Libraries and tools for dealing with media metadata
- build: `cargo hack --feature-powerset build` - build: `cargo hack --feature-powerset build`
- clippy: `cargo hack --feature-powerset clippy -- -D warnings` - clippy: `cargo hack --feature-powerset clippy -- -D warnings`
- test: `cargo hack --feature-powerset test` - test: `cargo hack --feature-powerset test`
- test old: `cargo +1.85 hack --feature-powerset test` - test old: `cargo +1.89 hack --feature-powerset test`
- fmt: `cargo fmt --check` - fmt: `cargo fmt --check`
- docs: `RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --all-features` - docs: `RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --all-features`
- install: `cargo install --path crates/cli` - install: `cargo install --path crates/cli`
- install: `cargo install --path crates/cli-mux`
- semver: `cargo semver-checks --all-features` - semver: `cargo semver-checks --all-features`
- publish: `cargo publish --dry-run --workspace` - publish: `cargo publish --dry-run --workspace`
## Building flix.db
`./flix.sh`
+57
View File
@@ -0,0 +1,57 @@
[package]
name = "flix-mux"
version = "0.0.19"
license-file.workspace = true
description = "CLI for bulk media muxing"
repository = "https://github.com/QuantumShade/flix"
categories = ["command-line-utilities"]
edition.workspace = true
rust-version.workspace = true
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[[bin]]
doc = false
name = "flix-mux"
path = "src/main.rs"
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true, features = [
"color",
"derive",
"error-context",
"help",
"std",
"suggestions",
"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"
+11
View File
@@ -0,0 +1,11 @@
# flix-mux
[![Crates Version](https://img.shields.io/crates/v/flix-mux.svg)](https://crates.io/crates/flix-mux)
CLI for bulk media muxing
## Commands
```sh
flix-mux -s 'v:0>eng;a:eng;s:eng:forced?;s:eng?;s:eng:sdh?' <DIR>
```
+49
View File
@@ -0,0 +1,49 @@
use std::path::PathBuf;
use clap::Parser;
use crate::parser::Selector;
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
/// Dry run and print commands
#[arg(short, long)]
dry_run: bool,
/// Stream selectors
#[arg(
short,
long,
required = true,
value_name = "SEL",
value_delimiter = ';'
)]
selectors: Vec<Selector>,
/// The path to the directory to scan
#[arg(value_name = "DIR")]
scan_dir: PathBuf,
}
impl Cli {
pub fn is_dry(&self) -> bool {
self.dry_run
}
pub 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(),
}
}
}
+66
View File
@@ -0,0 +1,66 @@
//! flix-mux
use core::time::Duration;
use clap::Parser;
use console::style;
use indicatif::{HumanBytes, ProgressBar, ProgressStyle};
use crate::mux::mux_files;
use crate::scan::scan_directory;
mod cli;
mod model;
mod mux;
mod parser;
mod probe;
mod scan;
fn main() {
let cli = cli::Cli::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.enable_steady_tick(Duration::from_millis(50));
progress
},
|progress| progress.finish(),
|len| {
let progress = ProgressBar::new(u64::try_from(len).unwrap_or(0));
progress.set_style(
#[expect(clippy::expect_used)]
ProgressStyle::with_template("[{elapsed_precise}] {wide_bar} {pos}/{len} ({msg})")
.expect("static template"),
);
progress
},
|progress| progress.inc(1),
|progress| progress.finish_and_clear(),
|progress, msg| {
progress.suspend(|| eprintln!("{} {}", style("[WARN]").bold().yellow(), msg))
},
);
println!("Found {} files ({})", files.len(), HumanBytes(size));
mux_files(
cli.is_dry(),
&files,
cli.selectors(),
|len| {
let progress = ProgressBar::new(u64::try_from(len).unwrap_or(0));
progress.set_style(
#[expect(clippy::expect_used)]
ProgressStyle::with_template("[{elapsed_precise}] {wide_bar} {pos}/{len} ({msg})")
.expect("static template"),
);
progress.enable_steady_tick(Duration::from_secs(1));
progress
},
|progress| progress.inc(1),
|progress| progress.finish_with_message("done"),
|progress, msg| progress.suspend(|| eprintln!("{} {}", style("[ERR]").bold().red(), msg)),
);
}
+101
View File
@@ -0,0 +1,101 @@
use std::path::PathBuf;
use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct MediaFile {
pub path: PathBuf,
pub byte_size: u64,
pub streams: Streams,
}
#[derive(Debug, Clone)]
pub struct Streams {
pub video: Vec<VideoStream>,
pub audio: Vec<AudioStream>,
pub subtitle: Vec<SubtitleStream>,
}
pub trait FFStream: serde::de::DeserializeOwned {
const FF_TYPE_NAME: &str;
}
#[derive(Debug, Clone, Deserialize)]
pub struct VideoStream {
codec_name: String,
tags: Option<VideoTags>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct VideoTags {
language: Option<String>,
}
impl VideoStream {
pub fn codec(&self) -> &str {
&self.codec_name
}
pub fn language(&self) -> Option<&str> {
self.tags.as_ref()?.language.as_deref()
}
}
impl FFStream for VideoStream {
const FF_TYPE_NAME: &str = "v";
}
#[derive(Debug, Clone, Deserialize)]
pub struct AudioStream {
codec_name: String,
tags: Option<AudioTags>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AudioTags {
language: Option<String>,
}
impl AudioStream {
pub fn codec(&self) -> &str {
&self.codec_name
}
pub fn language(&self) -> Option<&str> {
self.tags.as_ref()?.language.as_deref()
}
}
impl FFStream for AudioStream {
const FF_TYPE_NAME: &str = "a";
}
#[derive(Debug, Clone, Deserialize)]
pub struct SubtitleStream {
codec_name: String,
tags: Option<SubtitleTags>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SubtitleTags {
language: Option<String>,
title: Option<String>,
}
impl SubtitleStream {
pub fn codec(&self) -> &str {
&self.codec_name
}
pub fn language(&self) -> Option<&str> {
self.tags.as_ref()?.language.as_deref()
}
pub fn title(&self) -> Option<&str> {
self.tags.as_ref()?.title.as_deref()
}
}
impl FFStream for SubtitleStream {
const FF_TYPE_NAME: &str = "s";
}
+227
View File
@@ -0,0 +1,227 @@
use std::process::Command;
use anyhow::{Context as _, Result};
use crate::model::MediaFile;
use crate::parser::{Matcher, Selector, StreamFlag, StreamType};
pub fn mux_files<T>(
dry_run: bool,
files: &[MediaFile],
selectors: &[Selector],
fixed_length_start: impl FnOnce(usize) -> T,
fixed_length_update: impl Fn(&mut T),
fixed_length_end: impl FnOnce(T),
print_fn: impl Fn(&T, &str),
) {
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));
}
fixed_length_update(&mut progress);
}
fixed_length_end(progress);
}
#[expect(clippy::expect_used)]
fn mux(dry_run: bool, file: &MediaFile, selectors: &[Selector]) -> Result<()> {
let mut command = Command::new("ffmpeg");
let mut command = command.args(["-v", "error"]);
command = command.arg("-i");
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(["-c:v", "copy", "-c:a", "copy", "-c:s", "mov_text"]);
command = command.args(["-movflags", "faststart+disable_chpl+write_colr"]);
command = command.args(["-map_chapters", "-1"]);
command = command.args(["-map_metadata", "-1"]);
command = command.args(["-metadata:g", "encoding_tool=Skrundzflix"]);
for selector in selectors {
command = command.args(
make_metadata_args(file, selector)
.with_context(|| format!("Failed to mux {:?}", file.path))?,
);
}
let temp_path = file.path.with_extension("mp4");
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))?;
if !output.status.success() {
anyhow::bail!(
"ffmpeg failed for {:?}:\n\n{}",
file.path,
String::from_utf8_lossy(&output.stderr)
);
}
}
Ok(())
}
fn make_map_args(file: &MediaFile, selector: &Selector) -> Result<Vec<String>> {
let source_index = 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");
}
};
Ok(vec![
String::from("-map"),
format!("{}:{}:{}", source_index, stream_type, stream_index),
])
}
fn make_metadata_args(file: &MediaFile, selector: &Selector) -> Result<Vec<String>> {
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");
}
};
let stream_language = selector.language();
let mut args = vec![
format!("-metadata:s:{}:{}", stream_type, stream_index),
format!("language={}", stream_language),
];
if selector.stream_type == StreamType::Subtitle {
let sub_title = match selector.flag {
Some(StreamFlag::Forced) => "Forced",
Some(StreamFlag::Sdh) => "SDH",
None => match stream_language {
"eng" => "English",
"jpn" => "Japanese",
_ => anyhow::bail!("Unhandled subtitle language: {}", stream_language),
},
};
args.push(format!("-metadata:s:s:{}", stream_index));
args.push(format!("title={}", sub_title));
}
Ok(args)
}
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
.streams
.video
.iter()
.enumerate()
.filter(|(_, c)| c.language() == Some(language.as_str()))
.map(|(i, _)| i)
.next(),
Matcher::Codec(ref codec) => file
.streams
.video
.iter()
.enumerate()
.filter(|(_, c)| c.codec() == codec)
.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
.streams
.audio
.iter()
.enumerate()
.filter(|(_, c)| c.language() == Some(language.as_str()))
.map(|(i, _)| i)
.next(),
Matcher::Codec(ref codec) => file
.streams
.audio
.iter()
.enumerate()
.filter(|(_, c)| c.codec() == codec)
.map(|(i, _)| i)
.next(),
},
StreamType::Subtitle => match selector.matcher {
Matcher::Index(index) => (file.streams.subtitle.len() > index).then_some(index),
Matcher::Language(ref language) => {
file.streams
.subtitle
.iter()
.enumerate()
.filter(|(_, c)| c.language() == Some(language.as_str()))
.filter(|(_, c)| {
c.title()
.unwrap_or_default()
.to_ascii_lowercase()
.contains("forced") == needs_forced
})
.filter(|(_, c)| {
c.title()
.unwrap_or_default()
.to_ascii_lowercase()
.contains("sdh") == needs_sdh
})
.map(|(i, _)| i)
.next()
}
Matcher::Codec(ref codec) => file
.streams
.subtitle
.iter()
.enumerate()
.filter(|(_, c)| c.codec() == codec)
.map(|(i, _)| i)
.next(),
},
}
}
fn print_command(cmd: &Command) {
let program = cmd.get_program().to_string_lossy();
let args = cmd
.get_args()
.map(|a| shell_escape(a.to_string_lossy().as_ref()))
.collect::<Vec<_>>()
.join(" ");
println!("{} {}", program, args);
}
fn shell_escape(s: &str) -> String {
if s.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c))
{
s.to_string()
} else {
format!("{:?}", s)
}
}
+159
View File
@@ -0,0 +1,159 @@
use core::error::Error;
use core::fmt;
use core::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamType {
Video,
Audio,
Subtitle,
}
impl AsRef<str> for StreamType {
fn as_ref(&self) -> &str {
match self {
StreamType::Video => "v",
StreamType::Audio => "a",
StreamType::Subtitle => "s",
}
}
}
impl FromStr for StreamType {
type Err = ParseSelectorError;
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())),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Matcher {
Index(usize),
Language(String),
Codec(String),
}
impl FromStr for Matcher {
type Err = ParseSelectorError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Ok(idx) = s.parse::<usize>() {
return Ok(Matcher::Index(idx));
}
if s.len() == 3 {
return Ok(Matcher::Language(s.to_ascii_lowercase()));
}
Ok(Matcher::Codec(s.to_ascii_lowercase()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamFlag {
Forced,
Sdh,
}
impl FromStr for StreamFlag {
type Err = ParseSelectorError;
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())),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Selector {
pub stream_type: StreamType,
pub matcher: Matcher,
pub flag: Option<StreamFlag>,
pub optional: bool,
pub out_lang: String,
}
impl Selector {
pub fn language(&self) -> &str {
&self.out_lang
}
}
impl FromStr for Selector {
type Err = ParseSelectorError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
if input.is_empty() {
return Err(ParseSelectorError::Empty);
}
let (input, optional) = input
.strip_suffix('?')
.map_or((input, false), |s| (s, true));
let (left, out_lang) = input.split_once('>').unwrap_or((input, ""));
let mut parts = left.splitn(3, ':');
let stream_type: StreamType = parts
.next()
.ok_or(ParseSelectorError::InvalidFormat)?
.parse()?;
let matcher: Matcher = parts
.next()
.ok_or(ParseSelectorError::InvalidFormat)?
.parse()?;
let flag = parts
.next()
.filter(|s| !s.is_empty())
.map(str::parse)
.transpose()?;
let out_lang = match (out_lang.is_empty(), &matcher) {
(false, _) => out_lang.to_owned(),
(true, Matcher::Language(language)) => language.clone(),
(true, _) => return Err(ParseSelectorError::UnspecifiedLanguage),
};
Ok(Selector {
stream_type,
matcher,
flag,
optional,
out_lang,
})
}
}
#[derive(Debug)]
pub enum ParseSelectorError {
Empty,
InvalidFormat,
InvalidStreamType(String),
InvalidFlag(String),
UnspecifiedLanguage,
}
impl Error for ParseSelectorError {}
impl fmt::Display for ParseSelectorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
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::UnspecifiedLanguage => write!(f, "unspecified output language"),
}
}
}
+53
View File
@@ -0,0 +1,53 @@
use std::os::unix::fs::MetadataExt;
use std::process::Command;
use anyhow::{Context as _, Result};
use serde::Deserialize;
use walkdir::DirEntry;
use crate::model::{FFStream, MediaFile, Streams};
#[derive(Debug, Deserialize)]
struct FFProbeOutput<S> {
streams: Vec<S>,
}
fn probe_file_streams<S: FFStream>(entry: &DirEntry) -> Result<Vec<S>> {
let output = Command::new("ffprobe")
.args([
"-v",
"error",
"-output_format",
"json",
"-show_streams",
"-select_streams",
S::FF_TYPE_NAME,
#[expect(clippy::expect_used)]
entry.path().to_str().expect("path should be utf8"),
])
.output()
.with_context(|| format!("Failed to run ffprobe on {:?}", entry.path()))?;
if !output.status.success() {
anyhow::bail!(
"ffprobe failed for {:?}:\n\n{}",
entry.path(),
String::from_utf8_lossy(&output.stderr)
);
}
let parsed: FFProbeOutput<S> = serde_json::from_slice(&output.stdout)?;
Ok(parsed.streams)
}
pub fn probe_file(entry: &DirEntry) -> Result<MediaFile> {
Ok(MediaFile {
path: entry.path().to_path_buf(),
byte_size: entry.metadata()?.size(),
streams: Streams {
video: probe_file_streams(entry)?,
audio: probe_file_streams(entry)?,
subtitle: probe_file_streams(entry)?,
},
})
}
+58
View File
@@ -0,0 +1,58 @@
use std::path::Path;
use walkdir::WalkDir;
use crate::model::MediaFile;
use crate::probe::probe_file;
pub fn scan_directory<T>(
path: &Path,
unknown_length_start: impl FnOnce() -> T,
unknown_length_end: impl FnOnce(T),
fixed_length_start: impl FnOnce(usize) -> T,
fixed_length_update: impl Fn(&mut T),
fixed_length_end: impl FnOnce(T),
print_fn: impl Fn(&T, &str),
) -> (Vec<MediaFile>, u64) {
let spinner = unknown_length_start();
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
})
.collect();
unknown_length_end(spinner);
let mut progress = fixed_length_start(files.len());
let mut total_byte_size = 0u64;
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));
None
}
};
let size = file.as_ref().map(|r| r.byte_size).unwrap_or_default();
total_byte_size = total_byte_size.saturating_add(size);
fixed_length_update(&mut progress);
file
})
.collect();
fixed_length_end(progress);
(files, total_byte_size)
}
+29 -28
View File
@@ -1,14 +1,13 @@
[package] [package]
name = "flix-cli" name = "flix-cli"
version = "0.0.13" version = "0.0.19"
license-file.workspace = true
categories = ["command-line-utilities"]
description = "CLI for interacting with a flix database" description = "CLI for interacting with a flix database"
repository = "https://github.com/QuantumShade/flix" repository = "https://github.com/QuantumShade/flix"
categories = ["command-line-utilities"]
authors.workspace = true
edition.workspace = true edition.workspace = true
license-file.workspace = true
rust-version.workspace = true rust-version.workspace = true
[package.metadata.docs.rs] [package.metadata.docs.rs]
@@ -20,9 +19,26 @@ doc = false
name = "flix" name = "flix"
path = "src/main.rs" path = "src/main.rs"
[lints.rust] [dependencies]
arithmetic_overflow = "forbid" anyhow = { workspace = true }
unsafe_code = "forbid" chrono = { workspace = true, features = ["now"] }
clap = { workspace = true, features = [
"color",
"derive",
"error-context",
"help",
"std",
"suggestions",
"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"] }
toml = { workspace = true, features = ["parse", "serde"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[lints.clippy] [lints.clippy]
arithmetic_side_effects = "deny" arithmetic_side_effects = "deny"
@@ -34,26 +50,11 @@ indexing_slicing = "deny"
integer_division = "deny" integer_division = "deny"
integer_division_remainder_used = "deny" integer_division_remainder_used = "deny"
transmute_undefined_repr = "deny" transmute_undefined_repr = "deny"
unchecked_duration_subtraction = "deny" unchecked_time_subtraction = "deny"
unwrap_used = "deny" unwrap_used = "deny"
[dependencies] [lints.rust]
flix = { workspace = true, features = ["tmdb"] } arithmetic_overflow = "forbid"
missing_docs = "forbid"
anyhow = { workspace = true } unsafe_code = "forbid"
chrono = { workspace = true, features = ["now"] } unused_doc_comments = "forbid"
clap = { workspace = true, features = [
"derive",
"color",
"error-context",
"help",
"suggestions",
"usage",
] }
futures = { workspace = true }
sea-orm = { workspace = true, features = ["runtime-tokio", "debug-print"] }
serde = { workspace = true, features = ["derive"] }
tokio = { workspace = true, features = ["rt", "fs", "macros"] }
toml = { workspace = true, features = ["parse", "serde"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
+3 -2
View File
@@ -5,7 +5,8 @@
CLI for interacting with a flix database CLI for interacting with a flix database
## Commands ## Commands
```sh ```sh
cargo run -- init flix init
cargo run -- add tmdb movie <id> flix add tmdb movie <id>
``` ```
+30
View File
@@ -0,0 +1,30 @@
use flix::model::numbers::{EpisodeNumber, SeasonNumber};
use chrono::NaiveDate;
use clap::Subcommand;
#[derive(Subcommand)]
pub enum AddCommand {
/// Add a flix collection
Collection {
#[arg(value_name = "TITLE")]
title: String,
#[arg(value_name = "OVERVIEW")]
overview: String,
},
/// Add a flix episode
Episode {
#[arg(value_name = "SHOW_WEB_SLUG")]
show_slug: String,
#[arg(value_name = "NUMBER")]
season_number: SeasonNumber,
#[arg(value_name = "NUMBER")]
episode_number: EpisodeNumber,
#[arg(value_name = "TITLE")]
title: String,
#[arg(value_name = "OVERVIEW")]
overview: String,
#[arg(value_name = "DATE")]
air_date: NaiveDate,
},
}
+62 -25
View File
@@ -1,19 +1,29 @@
use std::path::PathBuf; use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use clap::{Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
pub mod flix;
pub mod tmdb; pub mod tmdb;
#[derive(Parser)] #[derive(Parser)]
#[command(version, about, long_about = None)] #[command(version, about, long_about = None)]
pub struct Cli { pub struct Cli {
/// Use a custom config file /// Use a custom config file
#[arg(short, long, value_name = "FILE", default_value = "~/.flix")] #[arg(
short,
long,
value_name = "FILE",
default_value = "~/.config/flix/config.toml"
)]
config: PathBuf, config: PathBuf,
/// 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 = "DATABASE", default_value = "./flix.db")] #[arg(short, long, value_name = "FILE", default_value = "./flix.db")]
database: PathBuf, database: PathBuf,
/// Enable tracing /// Enable tracing
@@ -37,6 +47,10 @@ impl Cli {
} }
} }
pub fn cache_path(&self) -> &Path {
&self.cache
}
pub fn database_path(&self) -> Result<String> { pub fn database_path(&self) -> Result<String> {
self.database self.database
.as_os_str() .as_os_str()
@@ -50,41 +64,43 @@ impl Cli {
} }
} }
#[derive(Args)]
pub struct AddOverrides {
#[arg(long)]
pub title: Option<String>,
#[arg(long)]
pub sort_title: Option<String>,
#[arg(long)]
pub fs_slug: Option<String>,
#[arg(long)]
pub web_slug: Option<String>,
}
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum Command { pub enum Command {
/// Initialize a new database /// Initialize a new database
Init, Init,
/// Add new items to the database /// Add new items to the database
Add { Add {
#[command(flatten)]
overrides: AddOverrides,
#[command(subcommand)] #[command(subcommand)]
command: BackendCommand, command: AddCommand,
}, },
/// Update an existing item in the database /// Update an existing item in the database
Update { Update {
#[command(subcommand)] #[command(subcommand)]
command: BackendCommand, command: UpdateCommand,
},
/// Delete an existing item in the database
Delete {
#[command(subcommand)]
command: BackendCommand,
},
/// Create a toml backup of the database
Backup {
/// Change the destination
#[arg(short, long, value_name = "FILE", default_value = "./flix.toml")]
output: PathBuf,
},
/// Create a database from a toml backup
Restore {
/// Change the source
#[arg(short, long, value_name = "FILE", default_value = "./flix.toml")]
input: PathBuf,
}, },
} }
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum BackendCommand { pub enum AddCommand {
/// Use the flix backend
Flix {
#[command(subcommand)]
command: flix::AddCommand,
},
/// Use the TMDB backend /// Use the TMDB backend
Tmdb { Tmdb {
#[command(subcommand)] #[command(subcommand)]
@@ -92,7 +108,28 @@ pub enum BackendCommand {
}, },
} }
impl From<tmdb::Command> for BackendCommand { impl From<flix::AddCommand> for AddCommand {
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 {
fn from(value: tmdb::Command) -> Self { fn from(value: tmdb::Command) -> Self {
Self::Tmdb { command: value } Self::Tmdb { command: value }
} }
-3
View File
@@ -1,3 +0,0 @@
//! Placeholder
#![cfg_attr(docsrs, feature(doc_cfg))]
+26 -37
View File
@@ -1,17 +1,21 @@
use std::path::PathBuf; //! flix-cli
use flix::tmdb::Client; use std::rc::Rc;
use flix::tmdb::{self, CachePolicy, Client, RedbCache};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Parser; use clap::Parser;
use tokio::fs; use tokio::fs;
mod cli; mod cli;
use cli::{BackendCommand, Cli, Command}; use cli::{AddCommand, Cli, Command, UpdateCommand};
mod config; mod config;
use config::Config; use config::Config;
use crate::cli::AddOverrides;
mod db; mod db;
mod run; mod run;
@@ -26,7 +30,9 @@ async fn main() -> Result<()> {
let database_path = cli.database_path()?; let database_path = cli.database_path()?;
let client = Client::new(config.tmdb().bearer_token().to_owned()); let config = tmdb::Config::new(config.tmdb().bearer_token().to_owned());
let cache = Rc::new(RedbCache::new(cli.cache_path())?);
let client = Client::new(config, cache, CachePolicy::Full);
if cli.trace { if cli.trace {
tracing_subscriber::fmt() tracing_subscriber::fmt()
@@ -37,11 +43,10 @@ async fn main() -> Result<()> {
match cli.command() { match cli.command() {
Command::Init => exec_init(database_path).await?, Command::Init => exec_init(database_path).await?,
Command::Add { command } => exec_add(client, database_path, command).await?, Command::Add { command, overrides } => {
exec_add(client, database_path, command, overrides).await?
}
Command::Update { command } => exec_update(client, database_path, command).await?, Command::Update { command } => exec_update(client, database_path, command).await?,
Command::Delete { command } => exec_delete(client, database_path, command).await?,
Command::Backup { output } => exec_backup(database_path, output).await?,
Command::Restore { input } => exec_restore(database_path, input).await?,
} }
Ok(()) Ok(())
@@ -53,50 +58,34 @@ async fn exec_init(database_path: String) -> Result<()> {
Ok(()) Ok(())
} }
async fn exec_add(client: Client, database_path: String, command: BackendCommand) -> Result<()> { async fn exec_add(
client: Client,
database_path: String,
command: AddCommand,
overrides: AddOverrides,
) -> Result<()> {
let database = db::open(database_path).await?; let database = db::open(database_path).await?;
match command { match command {
BackendCommand::Tmdb { command } => { AddCommand::Flix { command } => {
run::tmdb::add(client, database.as_ref(), command).await?; run::flix::add(database.as_ref(), command, overrides).await?;
}
AddCommand::Tmdb { command } => {
run::tmdb::add(client, database.as_ref(), command, overrides).await?;
} }
} }
Ok(()) Ok(())
} }
async fn exec_update(client: Client, database_path: String, command: BackendCommand) -> Result<()> { async fn exec_update(client: Client, database_path: String, command: UpdateCommand) -> Result<()> {
let database = db::open(database_path).await?; let database = db::open(database_path).await?;
match command { match command {
BackendCommand::Tmdb { command } => { UpdateCommand::Tmdb { command } => {
run::tmdb::update(client, database.as_ref(), command).await?; run::tmdb::update(client, database.as_ref(), command).await?;
} }
} }
Ok(()) Ok(())
} }
async fn exec_delete(client: Client, database_path: String, command: BackendCommand) -> Result<()> {
let database = db::open(database_path).await?;
match command {
BackendCommand::Tmdb { command } => {
run::tmdb::delete(client, database.as_ref(), command).await?;
}
}
Ok(())
}
async fn exec_backup(database_path: String, output: PathBuf) -> Result<()> {
_ = database_path;
_ = output;
unimplemented!()
}
async fn exec_restore(database_path: String, input: PathBuf) -> Result<()> {
_ = database_path;
_ = input;
unimplemented!()
}
+106
View File
@@ -0,0 +1,106 @@
use flix::db::entity;
use flix::model::id::{CollectionId, ShowId};
use flix::model::numbers::{EpisodeNumber, SeasonNumber};
use flix::model::text;
use anyhow::Result;
use sea_orm::ActiveValue::{NotSet, Set};
use sea_orm::{ActiveModelTrait, DatabaseConnection, DbErr, TransactionError, TransactionTrait};
use crate::cli::AddOverrides;
use crate::cli::flix::AddCommand;
pub async fn add(
db: &DatabaseConnection,
command: AddCommand,
overrides: AddOverrides,
) -> Result<()> {
match command {
AddCommand::Collection { title, overview } => {
let result: Result<CollectionId, TransactionError<DbErr>> = db
.transaction(|txn| {
let title = overrides.title.unwrap_or_else(|| title.clone());
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));
Box::pin(async move {
let flix = entity::info::collections::ActiveModel {
id: NotSet,
title: Set(title),
overview: Set(overview),
sort_title: Set(sort_title),
fs_slug: Set(fs_slug),
web_slug: Set(web_slug),
}
.insert(txn)
.await?;
Ok(flix.id)
})
})
.await;
match result {
Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?,
};
println!("Created Collection: {}", title);
Ok(())
}
AddCommand::Episode {
show_slug,
season_number,
episode_number,
title,
overview,
air_date,
} => {
let result: Result<(ShowId, SeasonNumber, EpisodeNumber), TransactionError<DbErr>> = db
.transaction(|txn| {
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)
.one(txn)
.await?
.ok_or_else(|| {
DbErr::Custom(format!("show '{}' does not exist", show_slug))
})?;
let flix = entity::info::episodes::ActiveModel {
show_id: Set(show.id),
season_number: Set(season_number),
episode_number: Set(episode_number),
title: Set(title),
overview: Set(overview),
date: Set(air_date),
}
.insert(txn)
.await?;
Ok((flix.show_id, flix.season_number, flix.episode_number))
})
})
.await;
match result {
Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?,
};
println!("Created Episode: {}", title);
Ok(())
}
}
}
+1
View File
@@ -1 +1,2 @@
pub mod flix;
pub mod tmdb; pub mod tmdb;
+664 -55
View File
@@ -3,21 +3,28 @@ use std::collections::HashMap;
use flix::db::entity; use flix::db::entity;
use flix::model::id::{CollectionId, MovieId, ShowId}; use flix::model::id::{CollectionId, MovieId, ShowId};
use flix::model::numbers::{EpisodeNumber, SeasonNumber}; use flix::model::numbers::{EpisodeNumber, SeasonNumber};
use flix::model::text;
use flix::tmdb::Client; use flix::tmdb::Client;
use flix::tmdb::model::id::{ use flix::tmdb::model::id::{
CollectionId as TmdbCollectionId, MovieId as TmdbMovieId, ShowId as TmdbShowId, CollectionId as TmdbCollectionId, MovieId as TmdbMovieId, ShowId as TmdbShowId,
}; };
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use chrono::Utc; use chrono::{Datelike, Utc};
use sea_orm::ActiveValue::{NotSet, Set}; use sea_orm::ActiveValue::{NotSet, Set};
use sea_orm::{ use sea_orm::{
ActiveModelTrait, DatabaseConnection, DbErr, EntityTrait, TransactionError, TransactionTrait, ActiveModelTrait, DatabaseConnection, DbErr, EntityTrait, TransactionError, TransactionTrait,
}; };
use crate::cli::AddOverrides;
use crate::cli::tmdb::Command; use crate::cli::tmdb::Command;
pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> Result<()> { pub async fn add(
client: Client,
db: &DatabaseConnection,
command: Command,
overrides: AddOverrides,
) -> Result<()> {
match command { match command {
Command::Collection { id } => { Command::Collection { id } => {
let id = TmdbCollectionId::from_raw(id); let id = TmdbCollectionId::from_raw(id);
@@ -35,13 +42,29 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
.await .await
.with_context(|| format!("collections().get_details({})", id.into_raw()))?; .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 let result: Result<CollectionId, TransactionError<DbErr>> = db
.transaction(|txn| { .transaction(|txn| {
let title = title.clone();
Box::pin(async move { Box::pin(async move {
let flix = entity::info::collections::ActiveModel { let flix = entity::info::collections::ActiveModel {
id: NotSet, id: NotSet,
title: Set(collection.title), title: Set(title),
overview: Set(collection.overview), overview: Set(collection.overview),
sort_title: Set(sort_title),
fs_slug: Set(fs_slug),
web_slug: Set(web_slug),
} }
.insert(txn) .insert(txn)
.await?; .await?;
@@ -49,7 +72,7 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
entity::tmdb::collections::ActiveModel { entity::tmdb::collections::ActiveModel {
tmdb_id: Set(id), tmdb_id: Set(id),
flix_id: Set(flix.id), flix_id: Set(flix.id),
last_update: Set(Utc::now().date_naive()), last_update: Set(Utc::now()),
movie_count: Set(collection.movies.len().try_into().unwrap_or(0)), movie_count: Set(collection.movies.len().try_into().unwrap_or(0)),
} }
.insert(txn) .insert(txn)
@@ -60,12 +83,12 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
}) })
.await; .await;
let flix_id = match result { match result {
Ok(id) => id, Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?, Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?, Err(TransactionError::Transaction(err)) => Err(err)?,
}; };
println!("Created Collection: {}", flix_id.into_raw()); println!("Created Collection: {}", title);
Ok(()) Ok(())
} }
@@ -83,15 +106,32 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
.await .await
.with_context(|| format!("movies().get_details({})", id.into_raw()))?; .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 let result: Result<MovieId, TransactionError<DbErr>> = db
.transaction(|txn| { .transaction(|txn| {
let title = title.clone();
Box::pin(async move { Box::pin(async move {
let flix = entity::info::movies::ActiveModel { let flix = entity::info::movies::ActiveModel {
id: NotSet, id: NotSet,
title: Set(movie.title), title: Set(title),
tagline: Set(movie.tagline), tagline: Set(movie.tagline),
overview: Set(movie.overview), overview: Set(movie.overview),
date: Set(movie.release_date), date: Set(movie.release_date),
sort_title: Set(sort_title),
fs_slug: Set(fs_slug),
web_slug: Set(web_slug),
} }
.insert(txn) .insert(txn)
.await?; .await?;
@@ -99,7 +139,7 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
entity::tmdb::movies::ActiveModel { entity::tmdb::movies::ActiveModel {
tmdb_id: Set(id), tmdb_id: Set(id),
flix_id: Set(flix.id), flix_id: Set(flix.id),
last_update: Set(Utc::now().date_naive()), last_update: Set(Utc::now()),
runtime: Set(movie.runtime.into()), runtime: Set(movie.runtime.into()),
collection_id: Set(movie.collection.map(|c| c.id)), collection_id: Set(movie.collection.map(|c| c.id)),
} }
@@ -111,12 +151,12 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
}) })
.await; .await;
let flix_id = match result { match result {
Ok(id) => id, Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?, Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?, Err(TransactionError::Transaction(err)) => Err(err)?,
}; };
println!("Created Movie: {}", flix_id.into_raw()); println!("Created Movie: {} ({})", title, year);
Ok(()) Ok(())
} }
@@ -137,14 +177,21 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
let mut episodes = HashMap::new(); let mut episodes = HashMap::new();
for season in 1..=show.number_of_seasons { for season in 1..=show.number_of_seasons {
let season = client let season = SeasonNumber::new(season);
let season = match client
.seasons() .seasons()
.get_details(id, season, None) .get_details(id, season, None)
.await .await
.with_context(|| { .with_context(|| {
format!("seasons().get_details({}, {})", id.into_raw(), season) format!("seasons().get_details({}, {})", id.into_raw(), season)
})?; }) {
if season.air_date > Utc::now().date_naive() { Ok(season) => season,
Err(err) => {
eprintln!("{err:?}");
continue;
}
};
if season.air_date > Utc::now().naive_utc().date() {
eprintln!( eprintln!(
"skipping season ({}, {})", "skipping season ({}, {})",
id.into_raw(), id.into_raw(),
@@ -153,7 +200,7 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
break; break;
} }
let Ok(number_of_episodes) = EpisodeNumber::try_from(season.episodes.len()) else { let Ok(number_of_episodes) = u32::try_from(season.episodes.len()) else {
bail!( bail!(
"could not convert {} to an EpisodeNumber", "could not convert {} to an EpisodeNumber",
season.episodes.len() season.episodes.len()
@@ -162,18 +209,23 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
let mut season_episodes = Vec::new(); let mut season_episodes = Vec::new();
for episode in 1..=number_of_episodes { for episode in 1..=number_of_episodes {
let Ok(episode) = client let episode = EpisodeNumber::new(episode);
let episode = match client
.episodes() .episodes()
.get_details(id, season.season_number, episode, None) .get_details(id, season.season_number, episode, None)
.await .await
else { {
eprintln!( Ok(value) => value,
"skipping episode ({}, {}, {})", Err(err) => {
id.into_raw(), eprintln!(
season.season_number, "skipping episode ({}, {}, {}) - {}",
episode id.into_raw(),
); season.season_number,
break; episode,
err
);
break;
}
}; };
season_episodes.push(episode); season_episodes.push(episode);
} }
@@ -182,15 +234,32 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
seasons.push(season); 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 let result: Result<ShowId, TransactionError<DbErr>> = db
.transaction(|txn| { .transaction(|txn| {
let title = title.clone();
Box::pin(async move { Box::pin(async move {
let flix = entity::info::shows::ActiveModel { let flix = entity::info::shows::ActiveModel {
id: NotSet, id: NotSet,
title: Set(show.title), title: Set(title),
tagline: Set(show.tagline), tagline: Set(show.tagline),
overview: Set(show.overview), overview: Set(show.overview),
date: Set(show.first_air_date), date: Set(show.first_air_date),
sort_title: Set(sort_title),
fs_slug: Set(fs_slug),
web_slug: Set(web_slug),
} }
.insert(txn) .insert(txn)
.await?; .await?;
@@ -198,7 +267,7 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
entity::tmdb::shows::ActiveModel { entity::tmdb::shows::ActiveModel {
tmdb_id: Set(id), tmdb_id: Set(id),
flix_id: Set(flix.id), flix_id: Set(flix.id),
last_update: Set(Utc::now().date_naive()), last_update: Set(Utc::now()),
number_of_seasons: Set(show.number_of_seasons), number_of_seasons: Set(show.number_of_seasons),
} }
.insert(txn) .insert(txn)
@@ -220,7 +289,7 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
tmdb_season: Set(season.season_number), tmdb_season: Set(season.season_number),
flix_show: Set(flix.id), flix_show: Set(flix.id),
flix_season: Set(season.season_number), flix_season: Set(season.season_number),
last_update: Set(Utc::now().date_naive()), last_update: Set(Utc::now()),
} }
.insert(txn) .insert(txn)
.await?; .await?;
@@ -246,7 +315,7 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
flix_show: Set(flix.id), flix_show: Set(flix.id),
flix_season: Set(season), flix_season: Set(season),
flix_episode: Set(episode.episode_number), flix_episode: Set(episode.episode_number),
last_update: Set(Utc::now().date_naive()), last_update: Set(Utc::now()),
runtime: Set(episode.runtime.into()), runtime: Set(episode.runtime.into()),
} }
.insert(txn) .insert(txn)
@@ -259,12 +328,12 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
}) })
.await; .await;
let flix_id = match result { match result {
Ok(id) => id, Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?, Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?, Err(TransactionError::Transaction(err)) => Err(err)?,
}; };
println!("Created Show: {}", flix_id.into_raw()); println!("Created Show: {} ({})", title, year);
Ok(()) Ok(())
} }
@@ -296,7 +365,7 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
})?; })?;
let mut episodes = Vec::new(); let mut episodes = Vec::new();
let Ok(number_of_episodes) = EpisodeNumber::try_from(season.episodes.len()) else { let Ok(number_of_episodes) = u32::try_from(season.episodes.len()) else {
bail!( bail!(
"could not convert {} to an EpisodeNumber", "could not convert {} to an EpisodeNumber",
season.episodes.len() season.episodes.len()
@@ -304,18 +373,23 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
}; };
for episode in 1..=number_of_episodes { for episode in 1..=number_of_episodes {
let Ok(episode) = client let episode = EpisodeNumber::new(episode);
let episode = match client
.episodes() .episodes()
.get_details(id, season.season_number, episode, None) .get_details(id, season.season_number, episode, None)
.await .await
else { {
eprintln!( Ok(value) => value,
"skipping episode ({}, {}, {})", Err(err) => {
id.into_raw(), eprintln!(
season.season_number, "skipping episode ({}, {}, {}) - {}",
episode id.into_raw(),
); season.season_number,
break; episode,
err
);
break;
}
}; };
episodes.push(episode); episodes.push(episode);
} }
@@ -338,7 +412,7 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
tmdb_season: Set(season_number), tmdb_season: Set(season_number),
flix_show: Set(show.flix_id), flix_show: Set(show.flix_id),
flix_season: Set(season_number), flix_season: Set(season_number),
last_update: Set(Utc::now().date_naive()), last_update: Set(Utc::now()),
} }
.insert(txn) .insert(txn)
.await?; .await?;
@@ -362,7 +436,7 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
flix_show: Set(show.flix_id), flix_show: Set(show.flix_id),
flix_season: Set(season_number), flix_season: Set(season_number),
flix_episode: Set(episode.episode_number), flix_episode: Set(episode.episode_number),
last_update: Set(Utc::now().date_naive()), last_update: Set(Utc::now()),
runtime: Set(episode.runtime.into()), runtime: Set(episode.runtime.into()),
} }
.insert(txn) .insert(txn)
@@ -375,7 +449,7 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
.await; .await;
match result { match result {
Ok(_) => (), Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?, Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?, Err(TransactionError::Transaction(err)) => Err(err)?,
}; };
@@ -453,7 +527,7 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
flix_show: Set(flix_id), flix_show: Set(flix_id),
flix_season: Set(season), flix_season: Set(season),
flix_episode: Set(episode_number), flix_episode: Set(episode_number),
last_update: Set(Utc::now().date_naive()), last_update: Set(Utc::now()),
runtime: Set(episode.runtime.into()), runtime: Set(episode.runtime.into()),
} }
.insert(txn) .insert(txn)
@@ -465,7 +539,7 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
.await; .await;
match result { match result {
Ok(_) => (), Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?, Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?, Err(TransactionError::Transaction(err)) => Err(err)?,
}; };
@@ -492,16 +566,551 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
} }
} }
pub async fn update(client: Client, database: &DatabaseConnection, command: Command) -> Result<()> { pub async fn update(client: Client, db: &DatabaseConnection, command: Command) -> Result<()> {
_ = client; _ = client;
_ = database; _ = db;
_ = command; _ = command;
unimplemented!("updates") unimplemented!("updates")
}
pub async fn delete(client: Client, database: &DatabaseConnection, command: Command) -> Result<()> { // match command {
_ = client; // Command::Collection { id } => {
_ = database; // let id = TmdbCollectionId::from_raw(id);
_ = command;
unimplemented!("deletions") // 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(())
// }
// }
} }
+15 -18
View File
@@ -1,45 +1,42 @@
[package] [package]
name = "flix-db" name = "flix-db"
version = "0.0.13" version = "0.0.19"
license-file.workspace = true
categories = []
description = "Types for storing persistent data about media" description = "Types for storing persistent data about media"
repository = "https://github.com/QuantumShade/flix" repository = "https://github.com/QuantumShade/flix"
categories = []
authors.workspace = true
edition.workspace = true edition.workspace = true
license-file.workspace = true
rust-version.workspace = true rust-version.workspace = true
[package.metadata.docs.rs] [package.metadata.docs.rs]
all-features = true all-features = true
rustdoc-args = ["--cfg", "docsrs"] rustdoc-args = ["--cfg", "docsrs"]
[lints]
workspace = true
[features]
default = []
tmdb = ["dep:flix-tmdb"]
[dependencies] [dependencies]
flix-model = { workspace = true }
flix-tmdb = { workspace = true, optional = true, features = ["sea-orm"] }
seamantic = { workspace = true, features = ["sqlite"] }
chrono = { workspace = true } chrono = { workspace = true }
flix-model = { workspace = true }
sea-orm = { workspace = true, features = [ sea-orm = { workspace = true, features = [
"entity-registry", "entity-registry",
"schema-sync", "schema-sync",
"with-chrono", "with-chrono",
] } ] }
sea-orm-migration = { workspace = true } sea-orm-migration = { workspace = true }
seamantic = { workspace = true, features = ["sqlite"] }
flix-tmdb = { workspace = true, features = ["sea-orm"], optional = true }
[dev-dependencies] [dev-dependencies]
sea-orm-migration = { workspace = true, features = ["runtime-tokio-rustls"] } sea-orm-migration = { workspace = true, features = ["runtime-tokio-rustls"] }
tokio = { version = "^1", default-features = false, features = [ tokio = { version = "^1", default-features = false, features = [
"rt",
"macros", "macros",
"rt",
] } ] }
[features]
default = []
tmdb = ["dep:flix-tmdb"]
[lints]
workspace = true
+191 -93
View File
@@ -4,8 +4,10 @@
pub mod libraries { pub mod libraries {
use flix_model::id::LibraryId; use flix_model::id::LibraryId;
use seamantic::model::duration::Seconds;
use seamantic::model::path::PathBytes; use seamantic::model::path::PathBytes;
use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
/// The database representation of a library media folder /// The database representation of a library media folder
@@ -14,25 +16,29 @@ pub mod libraries {
#[sea_orm(table_name = "flix_libraries")] #[sea_orm(table_name = "flix_libraries")]
pub struct Model { pub struct Model {
/// The library's ID /// The library's ID
#[sea_orm(column_type = "Integer", primary_key, nullable, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: LibraryId, pub id: LibraryId,
/// The library's directory /// The library's directory
pub directory: PathBytes, pub directory: PathBytes,
/// The library's last scan data
pub last_scan_date: Option<DateTime<Utc>>,
/// 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, on_update = "Cascade", on_delete = "Cascade")] #[sea_orm(has_many)]
pub collections: HasMany<super::collections::Entity>, pub collections: HasMany<super::collections::Entity>,
/// Movies that are part of this library /// Movies that are part of this library
#[sea_orm(has_many, on_update = "Cascade", on_delete = "Cascade")] #[sea_orm(has_many)]
pub movies: HasMany<super::movies::Entity>, pub movies: HasMany<super::movies::Entity>,
/// Shows that are part of this library /// Shows that are part of this library
#[sea_orm(has_many, on_update = "Cascade", on_delete = "Cascade")] #[sea_orm(has_many)]
pub shows: HasMany<super::shows::Entity>, pub shows: HasMany<super::shows::Entity>,
/// Seasons that are part of this library /// Seasons that are part of this library
#[sea_orm(has_many, on_update = "Cascade", on_delete = "Cascade")] #[sea_orm(has_many)]
pub seasons: HasMany<super::seasons::Entity>, pub seasons: HasMany<super::seasons::Entity>,
/// Episodes that are part of this library /// Episodes that are part of this library
#[sea_orm(has_many, on_update = "Cascade", on_delete = "Cascade")] #[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>, pub episodes: HasMany<super::episodes::Entity>,
} }
@@ -55,14 +61,11 @@ pub mod collections {
#[sea_orm(table_name = "flix_collections")] #[sea_orm(table_name = "flix_collections")]
pub struct Model { pub struct Model {
/// The collection's ID /// The collection's ID
#[sea_orm(column_type = "Integer", primary_key, nullable, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: CollectionId, pub id: CollectionId,
/// The collection's parent /// The collection's parent
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub parent_id: Option<CollectionId>, pub parent_id: Option<CollectionId>,
/// The collection's slug
#[sea_orm(unique)]
pub slug: String,
/// The collection's library ID /// The collection's library ID
pub library_id: LibraryId, pub library_id: LibraryId,
/// The collection's directory /// The collection's directory
@@ -71,14 +74,35 @@ pub mod collections {
pub relative_poster_path: Option<String>, pub relative_poster_path: Option<String>,
/// This collection's parent /// This collection's parent
#[sea_orm(self_ref, relation_enum = "Parent", from = "parent_id", to = "id")] #[sea_orm(
self_ref,
relation_enum = "Parent",
from = "parent_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub parent: HasOne<Entity>, pub parent: HasOne<Entity>,
/// The library this collection belongs to /// The library this collection belongs to
#[sea_orm(belongs_to, from = "library_id", to = "id")] #[sea_orm(
belongs_to,
from = "library_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub library: HasOne<super::libraries::Entity>, pub library: HasOne<super::libraries::Entity>,
/// The info for this collection /// The info for this collection
#[sea_orm(belongs_to, relation_enum = "Info", from = "id", to = "id")] #[sea_orm(
belongs_to,
relation_enum = "Info",
from = "id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub info: HasOne<entity::info::collections::Entity>, 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")] #[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")]
pub watched: HasMany<entity::watched::collections::Entity>, pub watched: HasMany<entity::watched::collections::Entity>,
@@ -103,14 +127,11 @@ pub mod movies {
#[sea_orm(table_name = "flix_movies")] #[sea_orm(table_name = "flix_movies")]
pub struct Model { pub struct Model {
/// The movie's ID /// The movie's ID
#[sea_orm(column_type = "Integer", primary_key, nullable, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: MovieId, pub id: MovieId,
/// The movie's parent /// The movie's parent
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub parent_id: Option<CollectionId>, pub parent_id: Option<CollectionId>,
/// The movie's slug
#[sea_orm(unique)]
pub slug: String,
/// The movie's library /// The movie's library
pub library_id: LibraryId, pub library_id: LibraryId,
/// The movie's directory /// The movie's directory
@@ -121,14 +142,34 @@ pub mod movies {
pub relative_poster_path: Option<String>, pub relative_poster_path: Option<String>,
/// This movie's parent /// This movie's parent
#[sea_orm(belongs_to, from = "parent_id", to = "id")] #[sea_orm(
belongs_to,
from = "parent_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub parent: HasOne<super::collections::Entity>, 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", to = "id")] #[sea_orm(
belongs_to,
from = "library_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub library: HasOne<super::libraries::Entity>, pub library: HasOne<super::libraries::Entity>,
/// The info for this movie /// The info for this movie
#[sea_orm(belongs_to, relation_enum = "Info", from = "id", to = "id")] #[sea_orm(
belongs_to,
relation_enum = "Info",
from = "id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub info: HasOne<entity::info::movies::Entity>, 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")] #[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")]
pub watched: HasMany<entity::watched::movies::Entity>, pub watched: HasMany<entity::watched::movies::Entity>,
@@ -153,14 +194,11 @@ pub mod shows {
#[sea_orm(table_name = "flix_shows")] #[sea_orm(table_name = "flix_shows")]
pub struct Model { pub struct Model {
/// The show's ID /// The show's ID
#[sea_orm(column_type = "Integer", primary_key, nullable, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: ShowId, pub id: ShowId,
/// The show's parent /// The show's parent
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub parent_id: Option<CollectionId>, pub parent_id: Option<CollectionId>,
/// The show's slug
#[sea_orm(unique)]
pub slug: String,
/// The show's library /// The show's library
pub library_id: LibraryId, pub library_id: LibraryId,
/// The show's directory /// The show's directory
@@ -169,14 +207,40 @@ pub mod shows {
pub relative_poster_path: Option<String>, pub relative_poster_path: Option<String>,
/// This show's parent /// This show's parent
#[sea_orm(belongs_to, from = "parent_id", to = "id")] #[sea_orm(
belongs_to,
from = "parent_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub parent: HasOne<super::collections::Entity>, 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", to = "id")] #[sea_orm(
belongs_to,
from = "library_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub library: HasOne<super::libraries::Entity>, pub library: HasOne<super::libraries::Entity>,
/// The info for this show /// The info for this show
#[sea_orm(belongs_to, relation_enum = "Info", from = "id", to = "id")] #[sea_orm(
belongs_to,
relation_enum = "Info",
from = "id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub info: HasOne<entity::info::shows::Entity>, pub info: HasOne<entity::info::shows::Entity>,
/// Seasons that are part of this show
#[sea_orm(has_many)]
pub seasons: HasMany<super::seasons::Entity>,
/// 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")] #[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")]
pub watched: HasMany<entity::watched::shows::Entity>, pub watched: HasMany<entity::watched::shows::Entity>,
@@ -207,9 +271,6 @@ pub mod seasons {
/// The season's number /// The season's number
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub season_number: SeasonNumber, pub season_number: SeasonNumber,
/// The season's slug
#[sea_orm(unique)]
pub slug: String,
/// The season's library /// The season's library
pub library_id: LibraryId, pub library_id: LibraryId,
/// The season's directory /// The season's directory
@@ -217,17 +278,38 @@ pub mod seasons {
/// The season's poster path /// The season's poster path
pub relative_poster_path: Option<String>, pub relative_poster_path: Option<String>,
/// This season's show
#[sea_orm(
belongs_to,
from = "show_id",
to = "id",
on_update = "Cascade",
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", to = "id")] #[sea_orm(
belongs_to,
from = "library_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub library: HasOne<super::libraries::Entity>, pub library: HasOne<super::libraries::Entity>,
/// The info for this season /// The info for this season
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
relation_enum = "Info", relation_enum = "Info",
from = "(show_id, season_number)", from = "(show_id, season_number)",
to = "(show_id, season_number)" to = "(show_id, season_number)",
on_update = "Cascade",
on_delete = "Cascade"
)] )]
pub info: HasOne<entity::info::seasons::Entity>, pub info: HasOne<entity::info::seasons::Entity>,
/// 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( #[sea_orm(
has_many, has_many,
@@ -268,9 +350,6 @@ pub mod episodes {
pub episode_number: EpisodeNumber, pub episode_number: EpisodeNumber,
/// The number of additional contained episodes /// The number of additional contained episodes
pub count: u8, pub count: u8,
/// The episode's slug
#[sea_orm(unique)]
pub slug: String,
/// The episode's library /// The episode's library
pub library_id: LibraryId, pub library_id: LibraryId,
/// The episode's directory /// The episode's directory
@@ -280,17 +359,44 @@ pub mod episodes {
/// The episode's poster path /// The episode's poster path
pub relative_poster_path: Option<String>, pub relative_poster_path: Option<String>,
/// This episode's show
#[sea_orm(
belongs_to,
from = "show_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub show: HasOne<super::shows::Entity>,
/// This episode's season
#[sea_orm(
belongs_to,
from = "(show_id, season_number)",
to = "(show_id, season_number)",
on_update = "Cascade",
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", to = "id")] #[sea_orm(
belongs_to,
from = "library_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub library: HasOne<super::libraries::Entity>, pub library: HasOne<super::libraries::Entity>,
/// The info for this episode /// The info for this episode
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
relation_enum = "Info", relation_enum = "Info",
from = "(show_id, season_number, episode_number)", from = "(show_id, season_number, episode_number)",
to = "(show_id, season_number, episode_number)" to = "(show_id, season_number, episode_number)",
on_update = "Cascade",
on_delete = "Cascade"
)] )]
pub info: HasOne<entity::info::episodes::Entity>, pub info: HasOne<entity::info::episodes::Entity>,
/// The watched info for this episode /// The watched info for this episode
#[sea_orm( #[sea_orm(
has_many, has_many,
@@ -308,10 +414,12 @@ pub mod episodes {
#[cfg(test)] #[cfg(test)]
pub mod test { pub mod test {
macro_rules! make_content_library { macro_rules! make_content_library {
($db:expr, $id:literal) => { ($db:expr, $id:expr) => {
$crate::entity::content::libraries::ActiveModel { $crate::entity::content::libraries::ActiveModel {
id: Set(::flix_model::id::LibraryId::from_raw($id)), id: Set(::flix_model::id::LibraryId::from_raw($id)),
directory: Set(::std::path::PathBuf::new().into()), directory: Set(::std::path::PathBuf::new().into()),
last_scan_date: Set(None),
last_scan_duration: Set(None),
} }
.insert($db) .insert($db)
.await .await
@@ -321,12 +429,11 @@ pub mod test {
pub(crate) use make_content_library; pub(crate) use make_content_library;
macro_rules! make_content_collection { macro_rules! make_content_collection {
($db:expr, $lid:literal, $id:literal, $pid:expr) => { ($db:expr, $lid:expr, $id:expr, $pid:expr) => {
$crate::entity::info::test::make_info_collection!($db, $id); $crate::entity::info::test::make_info_collection!($db, $id);
$crate::entity::content::collections::ActiveModel { $crate::entity::content::collections::ActiveModel {
id: Set(::flix_model::id::CollectionId::from_raw($id)), id: Set(::flix_model::id::CollectionId::from_raw($id)),
parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)), parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
slug: Set(concat!("C ", $id).to_string()),
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)), library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
directory: Set(::std::path::PathBuf::new().into()), directory: Set(::std::path::PathBuf::new().into()),
relative_poster_path: Set(::core::option::Option::None), relative_poster_path: Set(::core::option::Option::None),
@@ -339,12 +446,11 @@ pub mod test {
pub(crate) use make_content_collection; pub(crate) use make_content_collection;
macro_rules! make_content_movie { macro_rules! make_content_movie {
($db:expr, $lid:literal, $id:literal, $pid:expr) => { ($db:expr, $lid:expr, $id:expr, $pid:expr) => {
$crate::entity::info::test::make_info_movie!($db, $id); $crate::entity::info::test::make_info_movie!($db, $id);
$crate::entity::content::movies::ActiveModel { $crate::entity::content::movies::ActiveModel {
id: Set(::flix_model::id::MovieId::from_raw($id)), id: Set(::flix_model::id::MovieId::from_raw($id)),
parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)), parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
slug: Set(concat!("< ", $id).to_string()),
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)), library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
directory: Set(::std::path::PathBuf::new().into()), directory: Set(::std::path::PathBuf::new().into()),
relative_media_path: Set(::std::string::String::new()), relative_media_path: Set(::std::string::String::new()),
@@ -358,12 +464,11 @@ pub mod test {
pub(crate) use make_content_movie; pub(crate) use make_content_movie;
macro_rules! make_content_show { macro_rules! make_content_show {
($db:expr, $lid:literal, $id:literal, $pid:expr) => { ($db:expr, $lid:expr, $id:expr, $pid:expr) => {
$crate::entity::info::test::make_info_show!($db, $id); $crate::entity::info::test::make_info_show!($db, $id);
$crate::entity::content::shows::ActiveModel { $crate::entity::content::shows::ActiveModel {
id: Set(::flix_model::id::ShowId::from_raw($id)), id: Set(::flix_model::id::ShowId::from_raw($id)),
parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)), parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
slug: Set(concat!("S ", $id).to_string()),
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)), library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
directory: Set(::std::path::PathBuf::new().into()), directory: Set(::std::path::PathBuf::new().into()),
relative_poster_path: Set(::core::option::Option::None), relative_poster_path: Set(::core::option::Option::None),
@@ -376,12 +481,11 @@ pub mod test {
pub(crate) use make_content_show; pub(crate) use make_content_show;
macro_rules! make_content_season { macro_rules! make_content_season {
($db:expr, $lid:literal, $show:literal, $season:literal) => { ($db:expr, $lid:expr, $show:expr, $season:expr) => {
$crate::entity::info::test::make_info_season!($db, $show, $season); $crate::entity::info::test::make_info_season!($db, $show, $season);
$crate::entity::content::seasons::ActiveModel { $crate::entity::content::seasons::ActiveModel {
show_id: Set(::flix_model::id::ShowId::from_raw($show)), show_id: Set(::flix_model::id::ShowId::from_raw($show)),
season_number: Set($season), season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
slug: Set(concat!("SS ", $show, $season).to_string()),
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)), library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
directory: Set(::std::path::PathBuf::new().into()), directory: Set(::std::path::PathBuf::new().into()),
relative_poster_path: Set(::core::option::Option::None), relative_poster_path: Set(::core::option::Option::None),
@@ -394,20 +498,19 @@ pub mod test {
pub(crate) use make_content_season; pub(crate) use make_content_season;
macro_rules! make_content_episode { macro_rules! make_content_episode {
($db:expr, $lid:literal, $show:literal, $season:literal, $episode:literal) => { ($db:expr, $lid:expr, $show:expr, $season:expr, $episode:expr) => {
make_content_episode!(@make, $db, $lid, $show, $season, $episode, 0); make_content_episode!(@make, $db, $lid, $show, $season, $episode, 0);
}; };
($db:expr, $lid:literal, $show:literal, $season:literal, $episode:literal, >1) => { ($db:expr, $lid:literal, $show:literal, $season:literal, $episode:literal, >1) => {
make_content_episode!(@make, $db, $lid, $show, $season, $episode, 1); make_content_episode!(@make, $db, $lid, $show, $season, $episode, 1);
}; };
(@make, $db:expr, $lid:literal, $show:literal, $season:literal, $episode:literal, $count:literal) => { (@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::info::test::make_info_episode!($db, $show, $season, $episode);
$crate::entity::content::episodes::ActiveModel { $crate::entity::content::episodes::ActiveModel {
show_id: Set(::flix_model::id::ShowId::from_raw($show)), show_id: Set(::flix_model::id::ShowId::from_raw($show)),
season_number: Set($season), season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
episode_number: Set($episode), episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
count: Set($count), count: Set($count),
slug: Set(concat!("SSE ", $show, $season, $episode).to_string()),
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)), library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
directory: Set(::std::path::PathBuf::new().into()), directory: Set(::std::path::PathBuf::new().into()),
relative_media_path: Set(::std::string::String::new()), relative_media_path: Set(::std::string::String::new()),
@@ -423,10 +526,14 @@ pub mod test {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use core::time::Duration;
use std::path::Path; use std::path::Path;
use flix_model::id::{CollectionId, LibraryId, MovieId, ShowId}; use flix_model::id::{CollectionId, LibraryId, MovieId, ShowId};
use seamantic::model::duration::Seconds;
use chrono::NaiveDate;
use sea_orm::ActiveValue::{NotSet, Set}; use sea_orm::ActiveValue::{NotSet, Set};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use sea_orm::sqlx::error::ErrorKind; use sea_orm::sqlx::error::ErrorKind;
@@ -466,6 +573,8 @@ mod tests {
assert_eq!(model.id, LibraryId::from_raw($id)); assert_eq!(model.id, LibraryId::from_raw($id));
assert_eq!(model.directory, Path::new(concat!("L Directory ", $id)).to_owned().into()); assert_eq!(model.directory, Path::new(concat!("L Directory ", $id)).to_owned().into());
assert_eq!(model.last_scan_date, noneable!(last_scan_date, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc() $(, $($skip),+)?));
assert_eq!(model.last_scan_duration, noneable!(last_scan_duration, Seconds(Duration::from_secs($id)) $(, $($skip),+)?));
}; };
($db:expr, $id:literal, $error:ident $(; $($skip:ident),+)?) => { ($db:expr, $id:literal, $error:ident $(; $($skip:ident),+)?) => {
let model = assert_library!(@insert, $db, $id $(; $($skip),+)?) let model = assert_library!(@insert, $db, $id $(; $($skip),+)?)
@@ -477,6 +586,8 @@ mod tests {
super::libraries::ActiveModel { super::libraries::ActiveModel {
id: notsettable!(id, LibraryId::from_raw($id) $(, $($skip),+)?), id: notsettable!(id, LibraryId::from_raw($id) $(, $($skip),+)?),
directory: notsettable!(directory, Path::new(concat!("L Directory ", $id)).to_owned().into() $(, $($skip),+)?), directory: notsettable!(directory, Path::new(concat!("L Directory ", $id)).to_owned().into() $(, $($skip),+)?),
last_scan_date: notsettable!(last_scan_date, Some(NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc()) $(, $($skip),+)?),
last_scan_duration: notsettable!(last_scan_duration, Some(Seconds(Duration::from_secs($id))) $(, $($skip),+)?),
}.insert($db).await }.insert($db).await
}; };
} }
@@ -486,6 +597,8 @@ mod tests {
assert_library!(&db, 2, Success); assert_library!(&db, 2, Success);
assert_library!(&db, 3, Success; id); assert_library!(&db, 3, Success; id);
assert_library!(&db, 4, NotNullViolation; directory); assert_library!(&db, 4, NotNullViolation; directory);
assert_library!(&db, 5, Success; last_scan_date);
assert_library!(&db, 6, Success; last_scan_duration);
} }
#[tokio::test] #[tokio::test]
@@ -499,7 +612,6 @@ mod tests {
assert_eq!(model.id, CollectionId::from_raw($id)); assert_eq!(model.id, CollectionId::from_raw($id));
assert_eq!(model.parent_id, $pid.map(CollectionId::from_raw)); assert_eq!(model.parent_id, $pid.map(CollectionId::from_raw));
assert_eq!(model.slug, concat!("C Slug ", $id).to_string());
assert_eq!(model.library_id, LibraryId::from_raw($lid)); assert_eq!(model.library_id, LibraryId::from_raw($lid));
assert_eq!(model.directory, Path::new(concat!("C Directory ", $id)).to_owned().into()); assert_eq!(model.directory, Path::new(concat!("C Directory ", $id)).to_owned().into());
assert_eq!(model.relative_poster_path, noneable!(relative_poster_path, concat!("C Poster ", $id).to_owned() $(, $($skip),+)?)); assert_eq!(model.relative_poster_path, noneable!(relative_poster_path, concat!("C Poster ", $id).to_owned() $(, $($skip),+)?));
@@ -514,7 +626,6 @@ mod tests {
super::collections::ActiveModel { super::collections::ActiveModel {
id: notsettable!(id, CollectionId::from_raw($id) $(, $($skip),+)?), id: notsettable!(id, CollectionId::from_raw($id) $(, $($skip),+)?),
parent_id: notsettable!(parent_id, $pid.map(CollectionId::from_raw) $(, $($skip),+)?), parent_id: notsettable!(parent_id, $pid.map(CollectionId::from_raw) $(, $($skip),+)?),
slug: notsettable!(slug, concat!("C Slug ", $id).to_string() $(, $($skip),+)?),
library_id: notsettable!(library_id, LibraryId::from_raw($lid) $(, $($skip),+)?), library_id: notsettable!(library_id, LibraryId::from_raw($lid) $(, $($skip),+)?),
directory: notsettable!(directory, Path::new(concat!("C Directory ", $id)).to_owned().into() $(, $($skip),+)?), directory: notsettable!(directory, Path::new(concat!("C Directory ", $id)).to_owned().into() $(, $($skip),+)?),
relative_poster_path: notsettable!(relative_poster_path, Some(concat!("C Poster ", $id).to_owned()) $(, $($skip),+)?), relative_poster_path: notsettable!(relative_poster_path, Some(concat!("C Poster ", $id).to_owned()) $(, $($skip),+)?),
@@ -540,10 +651,9 @@ mod tests {
make_info_collection!(&db, 8); make_info_collection!(&db, 8);
assert_collection!(&db, 3, None, 1, Success; id); assert_collection!(&db, 3, None, 1, Success; id);
assert_collection!(&db, 4, None, 1, Success; parent_id); assert_collection!(&db, 4, None, 1, Success; parent_id);
assert_collection!(&db, 5, None, 1, NotNullViolation; slug); assert_collection!(&db, 5, None, 1, NotNullViolation; library_id);
assert_collection!(&db, 6, None, 1, NotNullViolation; library_id); assert_collection!(&db, 6, None, 1, NotNullViolation; directory);
assert_collection!(&db, 7, None, 1, NotNullViolation; directory); assert_collection!(&db, 7, None, 1, Success; relative_poster_path);
assert_collection!(&db, 8, None, 1, Success; relative_poster_path);
} }
#[tokio::test] #[tokio::test]
@@ -557,7 +667,6 @@ mod tests {
assert_eq!(model.id, MovieId::from_raw($id)); assert_eq!(model.id, MovieId::from_raw($id));
assert_eq!(model.parent_id, $pid.map(CollectionId::from_raw)); assert_eq!(model.parent_id, $pid.map(CollectionId::from_raw));
assert_eq!(model.slug, concat!("M Slug ", $id).to_string());
assert_eq!(model.library_id, LibraryId::from_raw($lid)); assert_eq!(model.library_id, LibraryId::from_raw($lid));
assert_eq!(model.directory, Path::new(concat!("M Directory ", $id)).to_owned().into()); assert_eq!(model.directory, Path::new(concat!("M Directory ", $id)).to_owned().into());
assert_eq!(model.relative_media_path, concat!("M Media ", $id)); assert_eq!(model.relative_media_path, concat!("M Media ", $id));
@@ -573,7 +682,6 @@ mod tests {
super::movies::ActiveModel { super::movies::ActiveModel {
id: notsettable!(id, MovieId::from_raw($id) $(, $($skip),+)?), id: notsettable!(id, MovieId::from_raw($id) $(, $($skip),+)?),
parent_id: notsettable!(parent_id, $pid.map(CollectionId::from_raw) $(, $($skip),+)?), parent_id: notsettable!(parent_id, $pid.map(CollectionId::from_raw) $(, $($skip),+)?),
slug: notsettable!(slug, concat!("M Slug ", $id).to_string() $(, $($skip),+)?),
library_id: notsettable!(library_id, LibraryId::from_raw($lid) $(, $($skip),+)?), library_id: notsettable!(library_id, LibraryId::from_raw($lid) $(, $($skip),+)?),
directory: notsettable!(directory, Path::new(concat!("M Directory ", $id)).to_owned().into() $(, $($skip),+)?), directory: notsettable!(directory, Path::new(concat!("M Directory ", $id)).to_owned().into() $(, $($skip),+)?),
relative_media_path: notsettable!(relative_media_path, concat!("M Media ", $id).to_owned() $(, $($skip),+)?), relative_media_path: notsettable!(relative_media_path, concat!("M Media ", $id).to_owned() $(, $($skip),+)?),
@@ -604,11 +712,10 @@ mod tests {
make_info_movie!(&db, 9); make_info_movie!(&db, 9);
assert_movie!(&db, 3, None, 1, Success; id); assert_movie!(&db, 3, None, 1, Success; id);
assert_movie!(&db, 4, None, 1, Success; parent_id); assert_movie!(&db, 4, None, 1, Success; parent_id);
assert_movie!(&db, 5, None, 1, NotNullViolation; slug); assert_movie!(&db, 5, None, 1, NotNullViolation; library_id);
assert_movie!(&db, 6, None, 1, NotNullViolation; library_id); assert_movie!(&db, 6, None, 1, NotNullViolation; directory);
assert_movie!(&db, 7, None, 1, NotNullViolation; directory); assert_movie!(&db, 7, None, 1, NotNullViolation; relative_media_path);
assert_movie!(&db, 8, None, 1, NotNullViolation; relative_media_path); assert_movie!(&db, 8, None, 1, Success; relative_poster_path);
assert_movie!(&db, 9, None, 1, Success; relative_poster_path);
} }
#[tokio::test] #[tokio::test]
@@ -622,7 +729,6 @@ mod tests {
assert_eq!(model.id, ShowId::from_raw($id)); assert_eq!(model.id, ShowId::from_raw($id));
assert_eq!(model.parent_id, $pid.map(CollectionId::from_raw)); assert_eq!(model.parent_id, $pid.map(CollectionId::from_raw));
assert_eq!(model.slug, concat!("S Slug ", $id).to_string());
assert_eq!(model.library_id, LibraryId::from_raw($lid)); assert_eq!(model.library_id, LibraryId::from_raw($lid));
assert_eq!(model.directory, Path::new(concat!("S Directory ", $id)).to_owned().into()); assert_eq!(model.directory, Path::new(concat!("S Directory ", $id)).to_owned().into());
assert_eq!(model.relative_poster_path, noneable!(relative_poster_path, concat!("S Poster ", $id).to_owned() $(, $($skip),+)?)); assert_eq!(model.relative_poster_path, noneable!(relative_poster_path, concat!("S Poster ", $id).to_owned() $(, $($skip),+)?));
@@ -637,7 +743,6 @@ mod tests {
super::shows::ActiveModel { super::shows::ActiveModel {
id: notsettable!(id, ShowId::from_raw($id) $(, $($skip),+)?), id: notsettable!(id, ShowId::from_raw($id) $(, $($skip),+)?),
parent_id: notsettable!(parent_id, $pid.map(CollectionId::from_raw) $(, $($skip),+)?), parent_id: notsettable!(parent_id, $pid.map(CollectionId::from_raw) $(, $($skip),+)?),
slug: notsettable!(slug, concat!("S Slug ", $id).to_string() $(, $($skip),+)?),
library_id: notsettable!(library_id, LibraryId::from_raw($lid) $(, $($skip),+)?), library_id: notsettable!(library_id, LibraryId::from_raw($lid) $(, $($skip),+)?),
directory: notsettable!(directory, Path::new(concat!("S Directory ", $id)).to_owned().into() $(, $($skip),+)?), directory: notsettable!(directory, Path::new(concat!("S Directory ", $id)).to_owned().into() $(, $($skip),+)?),
relative_poster_path: notsettable!(relative_poster_path, Some(concat!("S Poster ", $id).to_owned()) $(, $($skip),+)?), relative_poster_path: notsettable!(relative_poster_path, Some(concat!("S Poster ", $id).to_owned()) $(, $($skip),+)?),
@@ -666,10 +771,9 @@ mod tests {
make_info_show!(&db, 8); make_info_show!(&db, 8);
assert_show!(&db, 3, None, 1, Success; id); assert_show!(&db, 3, None, 1, Success; id);
assert_show!(&db, 4, None, 1, Success; parent_id); assert_show!(&db, 4, None, 1, Success; parent_id);
assert_show!(&db, 5, None, 1, NotNullViolation; slug); assert_show!(&db, 5, None, 1, NotNullViolation; library_id);
assert_show!(&db, 6, None, 1, NotNullViolation; library_id); assert_show!(&db, 6, None, 1, NotNullViolation; directory);
assert_show!(&db, 7, None, 1, NotNullViolation; directory); assert_show!(&db, 7, None, 1, Success; relative_poster_path);
assert_show!(&db, 8, None, 1, Success; relative_poster_path);
} }
#[tokio::test] #[tokio::test]
@@ -682,8 +786,7 @@ mod tests {
.expect("insert"); .expect("insert");
assert_eq!(model.show_id, ShowId::from_raw($id)); assert_eq!(model.show_id, ShowId::from_raw($id));
assert_eq!(model.season_number, $season); assert_eq!(model.season_number, ::flix_model::numbers::SeasonNumber::new($season));
assert_eq!(model.slug, concat!("SS Slug ", $id, ",", $season).to_string());
assert_eq!(model.library_id, LibraryId::from_raw($lid)); assert_eq!(model.library_id, LibraryId::from_raw($lid));
assert_eq!(model.directory, Path::new(concat!("SS Directory ", $id, ",", $season)).to_owned().into()); assert_eq!(model.directory, Path::new(concat!("SS Directory ", $id, ",", $season)).to_owned().into());
assert_eq!(model.relative_poster_path, noneable!(relative_poster_path, concat!("SS Poster ", $id, ",", $season).to_owned() $(, $($skip),+)?)); assert_eq!(model.relative_poster_path, noneable!(relative_poster_path, concat!("SS Poster ", $id, ",", $season).to_owned() $(, $($skip),+)?));
@@ -697,8 +800,7 @@ mod tests {
(@insert, $db:expr, $id:literal, $season:literal, $lid:literal $(; $($skip:ident),+)?) => { (@insert, $db:expr, $id:literal, $season:literal, $lid:literal $(; $($skip:ident),+)?) => {
super::seasons::ActiveModel { super::seasons::ActiveModel {
show_id: notsettable!(show_id, ShowId::from_raw($id) $(, $($skip),+)?), show_id: notsettable!(show_id, ShowId::from_raw($id) $(, $($skip),+)?),
season_number: notsettable!(season_number, $season $(, $($skip),+)?), season_number: notsettable!(season_number, ::flix_model::numbers::SeasonNumber::new($season) $(, $($skip),+)?),
slug: notsettable!(slug, concat!("SS Slug ", $id, ",", $season).to_string() $(, $($skip),+)?),
library_id: notsettable!(library_id, LibraryId::from_raw($lid) $(, $($skip),+)?), library_id: notsettable!(library_id, LibraryId::from_raw($lid) $(, $($skip),+)?),
directory: notsettable!(directory, Path::new(concat!("SS Directory ", $id, ",", $season)).to_owned().into() $(, $($skip),+)?), directory: notsettable!(directory, Path::new(concat!("SS Directory ", $id, ",", $season)).to_owned().into() $(, $($skip),+)?),
relative_poster_path: notsettable!(relative_poster_path, Some(concat!("SS Poster ", $id, ",", $season).to_owned()) $(, $($skip),+)?), relative_poster_path: notsettable!(relative_poster_path, Some(concat!("SS Poster ", $id, ",", $season).to_owned()) $(, $($skip),+)?),
@@ -707,7 +809,7 @@ mod tests {
} }
make_content_library!(&db, 1); make_content_library!(&db, 1);
make_info_show!(&db, 1); make_content_show!(&db, 1, 1, None);
assert_season!(&db, 1, 1, 1, ForeignKeyViolation); assert_season!(&db, 1, 1, 1, ForeignKeyViolation);
make_info_season!(&db, 1, 1); make_info_season!(&db, 1, 1);
assert_season!(&db, 1, 1, 1, Success); assert_season!(&db, 1, 1, 1, Success);
@@ -721,10 +823,9 @@ mod tests {
make_info_season!(&db, 1, 8); make_info_season!(&db, 1, 8);
assert_season!(&db, 1, 3, 1, NotNullViolation; show_id); assert_season!(&db, 1, 3, 1, NotNullViolation; show_id);
assert_season!(&db, 1, 4, 1, NotNullViolation; season_number); assert_season!(&db, 1, 4, 1, NotNullViolation; season_number);
assert_season!(&db, 1, 5, 1, NotNullViolation; slug); assert_season!(&db, 1, 5, 1, NotNullViolation; library_id);
assert_season!(&db, 1, 6, 1, NotNullViolation; library_id); assert_season!(&db, 1, 6, 1, NotNullViolation; directory);
assert_season!(&db, 1, 7, 1, NotNullViolation; directory); assert_season!(&db, 1, 7, 1, Success; relative_poster_path);
assert_season!(&db, 1, 8, 1, Success; relative_poster_path);
} }
#[tokio::test] #[tokio::test]
@@ -737,9 +838,8 @@ mod tests {
.expect("insert"); .expect("insert");
assert_eq!(model.show_id, ShowId::from_raw($id)); assert_eq!(model.show_id, ShowId::from_raw($id));
assert_eq!(model.season_number, $season); assert_eq!(model.season_number, ::flix_model::numbers::SeasonNumber::new($season));
assert_eq!(model.episode_number, $episode); assert_eq!(model.episode_number, ::flix_model::numbers::EpisodeNumber::new($episode));
assert_eq!(model.slug, concat!("SS Slug ", $id, ",", $season, $episode).to_string());
assert_eq!(model.library_id, LibraryId::from_raw($lid)); assert_eq!(model.library_id, LibraryId::from_raw($lid));
assert_eq!(model.directory, Path::new(concat!("SS Directory ", $id, ",", $season, $episode)).to_owned().into()); assert_eq!(model.directory, Path::new(concat!("SS Directory ", $id, ",", $season, $episode)).to_owned().into());
assert_eq!(model.relative_media_path, concat!("SS Media ", $id, ",", $season, $episode)); assert_eq!(model.relative_media_path, concat!("SS Media ", $id, ",", $season, $episode));
@@ -754,10 +854,9 @@ mod tests {
(@insert, $db:expr, $id:literal, $season:literal, $episode:literal, $lid:literal $(; $($skip:ident),+)?) => { (@insert, $db:expr, $id:literal, $season:literal, $episode:literal, $lid:literal $(; $($skip:ident),+)?) => {
super::episodes::ActiveModel { super::episodes::ActiveModel {
show_id: notsettable!(show_id, ShowId::from_raw($id) $(, $($skip),+)?), show_id: notsettable!(show_id, ShowId::from_raw($id) $(, $($skip),+)?),
season_number: notsettable!(season_number, $season $(, $($skip),+)?), season_number: notsettable!(season_number, ::flix_model::numbers::SeasonNumber::new($season) $(, $($skip),+)?),
episode_number: notsettable!(episode_number, $episode $(, $($skip),+)?), episode_number: notsettable!(episode_number, ::flix_model::numbers::EpisodeNumber::new($episode) $(, $($skip),+)?),
count: notsettable!(count, 0 $(, $($skip),+)?), count: notsettable!(count, 0 $(, $($skip),+)?),
slug: notsettable!(slug, concat!("SS Slug ", $id, ",", $season, $episode).to_string() $(, $($skip),+)?),
library_id: notsettable!(library_id, LibraryId::from_raw($lid) $(, $($skip),+)?), library_id: notsettable!(library_id, LibraryId::from_raw($lid) $(, $($skip),+)?),
directory: notsettable!(directory, Path::new(concat!("SS Directory ", $id, ",", $season, $episode)).to_owned().into() $(, $($skip),+)?), directory: notsettable!(directory, Path::new(concat!("SS Directory ", $id, ",", $season, $episode)).to_owned().into() $(, $($skip),+)?),
relative_media_path: notsettable!(relative_media_path, concat!("SS Media ", $id, ",", $season, $episode).to_owned() $(, $($skip),+)?), relative_media_path: notsettable!(relative_media_path, concat!("SS Media ", $id, ",", $season, $episode).to_owned() $(, $($skip),+)?),
@@ -767,8 +866,8 @@ mod tests {
} }
make_content_library!(&db, 1); make_content_library!(&db, 1);
make_info_show!(&db, 1); make_content_show!(&db, 1, 1, None);
make_info_season!(&db, 1, 1); make_content_season!(&db, 1, 1, 1);
assert_episode!(&db, 1, 1, 1, 1, ForeignKeyViolation); assert_episode!(&db, 1, 1, 1, 1, ForeignKeyViolation);
make_info_episode!(&db, 1, 1, 1); make_info_episode!(&db, 1, 1, 1);
assert_episode!(&db, 1, 1, 1, 1, Success); assert_episode!(&db, 1, 1, 1, 1, Success);
@@ -785,10 +884,9 @@ mod tests {
assert_episode!(&db, 1, 1, 3, 1, NotNullViolation; show_id); assert_episode!(&db, 1, 1, 3, 1, NotNullViolation; show_id);
assert_episode!(&db, 1, 1, 4, 1, NotNullViolation; season_number); assert_episode!(&db, 1, 1, 4, 1, NotNullViolation; season_number);
assert_episode!(&db, 1, 1, 5, 1, NotNullViolation; episode_number); assert_episode!(&db, 1, 1, 5, 1, NotNullViolation; episode_number);
assert_episode!(&db, 1, 1, 6, 1, NotNullViolation; slug); assert_episode!(&db, 1, 1, 6, 1, NotNullViolation; library_id);
assert_episode!(&db, 1, 1, 7, 1, NotNullViolation; library_id); assert_episode!(&db, 1, 1, 7, 1, NotNullViolation; directory);
assert_episode!(&db, 1, 1, 8, 1, NotNullViolation; directory); assert_episode!(&db, 1, 1, 8, 1, NotNullViolation; relative_media_path);
assert_episode!(&db, 1, 1, 9, 1, NotNullViolation; relative_media_path); assert_episode!(&db, 1, 1, 9, 1, Success; relative_poster_path);
assert_episode!(&db, 1, 1, 10, 1, Success; relative_poster_path);
} }
} }
+124 -26
View File
@@ -7,19 +7,34 @@ pub mod collections {
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use crate::entity;
/// The database representation of a flix collection /// The database representation of a flix collection
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_info_collections")] #[sea_orm(table_name = "flix_info_collections")]
pub struct Model { pub struct Model {
/// The collection's ID /// The collection's ID
#[sea_orm(column_type = "Integer", primary_key, nullable, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: CollectionId, pub id: CollectionId,
/// The collection's title /// The collection's title
#[sea_orm(indexed)]
pub title: String, pub title: String,
/// The collection's overview /// The collection's overview
pub overview: String, pub overview: String,
/// The sortable title
#[sea_orm(indexed)]
pub sort_title: String,
/// The filesystem-safe slug
#[sea_orm(indexed, unique)]
pub fs_slug: String,
/// The url-safe slug
#[sea_orm(indexed, unique)]
pub web_slug: String,
/// Potential content for this collection
#[sea_orm(has_one)]
pub content: HasOne<entity::content::collections::Entity>,
} }
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
@@ -32,16 +47,17 @@ pub mod movies {
use chrono::NaiveDate; use chrono::NaiveDate;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use crate::entity;
/// The database representation of a flix movie /// The database representation of a flix movie
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_info_movies")] #[sea_orm(table_name = "flix_info_movies")]
pub struct Model { pub struct Model {
/// The movie's ID /// The movie's ID
#[sea_orm(column_type = "Integer", primary_key, nullable, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: MovieId, pub id: MovieId,
/// The movie's title /// The movie's title
#[sea_orm(indexed)]
pub title: String, pub title: String,
/// The movie's tagline /// The movie's tagline
pub tagline: String, pub tagline: String,
@@ -50,6 +66,20 @@ pub mod movies {
/// The movie's release date /// The movie's release date
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub date: NaiveDate, pub date: NaiveDate,
/// The sortable title
#[sea_orm(indexed)]
pub sort_title: String,
/// The filesystem-safe slug
#[sea_orm(indexed, unique)]
pub fs_slug: String,
/// The url-safe slug
#[sea_orm(indexed, unique)]
pub web_slug: String,
/// Potential content for this movie
#[sea_orm(has_one)]
pub content: HasOne<entity::content::movies::Entity>,
} }
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
@@ -62,16 +92,17 @@ pub mod shows {
use chrono::NaiveDate; use chrono::NaiveDate;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use crate::entity;
/// The database representation of a flix show /// The database representation of a flix show
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_info_shows")] #[sea_orm(table_name = "flix_info_shows")]
pub struct Model { pub struct Model {
/// The show's ID /// The show's ID
#[sea_orm(column_type = "Integer", primary_key, nullable, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: ShowId, pub id: ShowId,
/// The show's title /// The show's title
#[sea_orm(indexed)]
pub title: String, pub title: String,
/// The show's tagline /// The show's tagline
pub tagline: String, pub tagline: String,
@@ -81,12 +112,26 @@ pub mod shows {
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub date: NaiveDate, pub date: NaiveDate,
/// The sortable title
#[sea_orm(indexed)]
pub sort_title: String,
/// The filesystem-safe slug
#[sea_orm(indexed, unique)]
pub fs_slug: String,
/// 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, on_update = "Cascade", on_delete = "Cascade")] #[sea_orm(has_many)]
pub seasons: HasMany<super::seasons::Entity>, pub seasons: HasMany<super::seasons::Entity>,
/// Episodes that are part of this show /// Episodes that are part of this show
#[sea_orm(has_many, on_update = "Cascade", on_delete = "Cascade")] #[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>, pub episodes: HasMany<super::episodes::Entity>,
/// Potential content for this show
#[sea_orm(has_one)]
pub content: HasOne<entity::content::shows::Entity>,
} }
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
@@ -100,6 +145,8 @@ pub mod seasons {
use chrono::NaiveDate; use chrono::NaiveDate;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use crate::entity;
/// The database representation of a flix season /// The database representation of a flix season
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
@@ -120,11 +167,21 @@ pub mod seasons {
pub date: NaiveDate, pub date: NaiveDate,
/// The show this season belongs to /// The show this season belongs to
#[sea_orm(belongs_to, from = "show_id", to = "id")] #[sea_orm(
belongs_to,
from = "show_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub show: HasOne<super::shows::Entity>, pub show: HasOne<super::shows::Entity>,
/// Episodes that are part of this season /// Episodes that are part of this season
#[sea_orm(has_many, on_update = "Cascade", on_delete = "Cascade")] #[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>, pub episodes: HasMany<super::episodes::Entity>,
/// Potential content for this season
#[sea_orm(has_one)]
pub content: HasOne<entity::content::seasons::Entity>,
} }
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
@@ -138,6 +195,8 @@ pub mod episodes {
use chrono::NaiveDate; use chrono::NaiveDate;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use crate::entity;
/// The database representation of a flix episode /// The database representation of a flix episode
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
@@ -161,15 +220,27 @@ pub mod episodes {
pub date: NaiveDate, pub date: NaiveDate,
/// The show this episode belongs to /// The show this episode belongs to
#[sea_orm(belongs_to, from = "show_id", to = "id")] #[sea_orm(
belongs_to,
from = "show_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub show: HasOne<super::shows::Entity>, pub show: HasOne<super::shows::Entity>,
/// The season this episode belongs to /// The season this episode belongs to
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "(show_id, season_number)", from = "(show_id, season_number)",
to = "(show_id, season_number)" to = "(show_id, season_number)",
on_update = "Cascade",
on_delete = "Cascade"
)] )]
pub season: HasOne<super::seasons::Entity>, pub season: HasOne<super::seasons::Entity>,
/// Potential content for this episode
#[sea_orm(has_one)]
pub content: HasOne<entity::content::episodes::Entity>,
} }
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
@@ -179,11 +250,14 @@ pub mod episodes {
#[cfg(test)] #[cfg(test)]
pub mod test { pub mod test {
macro_rules! make_info_collection { macro_rules! make_info_collection {
($db:expr, $id:literal) => { ($db:expr, $id:expr) => {
$crate::entity::info::collections::ActiveModel { $crate::entity::info::collections::ActiveModel {
id: Set(::flix_model::id::CollectionId::from_raw($id)), id: Set(::flix_model::id::CollectionId::from_raw($id)),
title: Set(::std::string::String::new()), title: Set(::std::string::String::new()),
overview: 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) .insert($db)
.await .await
@@ -193,13 +267,16 @@ pub mod test {
pub(crate) use make_info_collection; pub(crate) use make_info_collection;
macro_rules! make_info_movie { macro_rules! make_info_movie {
($db:expr, $id:literal) => { ($db:expr, $id:expr) => {
$crate::entity::info::movies::ActiveModel { $crate::entity::info::movies::ActiveModel {
id: Set(::flix_model::id::MovieId::from_raw($id)), id: Set(::flix_model::id::MovieId::from_raw($id)),
title: Set(::std::string::String::new()), title: Set(::std::string::String::new()),
tagline: Set(::std::string::String::new()), tagline: Set(::std::string::String::new()),
overview: Set(::std::string::String::new()), overview: Set(::std::string::String::new()),
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")), 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) .insert($db)
.await .await
@@ -209,13 +286,16 @@ pub mod test {
pub(crate) use make_info_movie; pub(crate) use make_info_movie;
macro_rules! make_info_show { macro_rules! make_info_show {
($db:expr, $id:literal) => { ($db:expr, $id:expr) => {
$crate::entity::info::shows::ActiveModel { $crate::entity::info::shows::ActiveModel {
id: Set(::flix_model::id::ShowId::from_raw($id)), id: Set(::flix_model::id::ShowId::from_raw($id)),
title: Set(::std::string::String::new()), title: Set(::std::string::String::new()),
tagline: Set(::std::string::String::new()), tagline: Set(::std::string::String::new()),
overview: Set(::std::string::String::new()), overview: Set(::std::string::String::new()),
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")), 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) .insert($db)
.await .await
@@ -225,10 +305,10 @@ pub mod test {
pub(crate) use make_info_show; pub(crate) use make_info_show;
macro_rules! make_info_season { macro_rules! make_info_season {
($db:expr, $show:literal, $season:literal) => { ($db:expr, $show:expr, $season:expr) => {
$crate::entity::info::seasons::ActiveModel { $crate::entity::info::seasons::ActiveModel {
show_id: Set(::flix_model::id::ShowId::from_raw($show)), show_id: Set(::flix_model::id::ShowId::from_raw($show)),
season_number: Set($season), season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
title: Set(::std::string::String::new()), title: Set(::std::string::String::new()),
overview: Set(::std::string::String::new()), overview: Set(::std::string::String::new()),
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")), date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
@@ -241,11 +321,11 @@ pub mod test {
pub(crate) use make_info_season; pub(crate) use make_info_season;
macro_rules! make_info_episode { macro_rules! make_info_episode {
($db:expr, $show:literal, $season:literal, $episode:literal) => { ($db:expr, $show:expr, $season:expr, $episode:expr) => {
$crate::entity::info::episodes::ActiveModel { $crate::entity::info::episodes::ActiveModel {
show_id: Set(::flix_model::id::ShowId::from_raw($show)), show_id: Set(::flix_model::id::ShowId::from_raw($show)),
season_number: Set($season), season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
episode_number: Set($episode), episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
title: Set(::std::string::String::new()), title: Set(::std::string::String::new()),
overview: Set(::std::string::String::new()), overview: Set(::std::string::String::new()),
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")), date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
@@ -310,6 +390,9 @@ mod tests {
id: notsettable!(id, CollectionId::from_raw($id) $(, $($skip),+)?), id: notsettable!(id, CollectionId::from_raw($id) $(, $($skip),+)?),
title: notsettable!(title, concat!("C Title ", $id).to_string() $(, $($skip),+)?), title: notsettable!(title, concat!("C Title ", $id).to_string() $(, $($skip),+)?),
overview: notsettable!(overview, concat!("C Overview ", $id).to_string() $(, $($skip),+)?), overview: notsettable!(overview, concat!("C Overview ", $id).to_string() $(, $($skip),+)?),
sort_title: notsettable!(sort_title, concat!("C Sort Title ", $id).to_string() $(, $($skip),+)?),
fs_slug: notsettable!(fs_slug, concat!("C FS Slug ", $id).to_string() $(, $($skip),+)?),
web_slug: notsettable!(web_slug, concat!("C Web Slug ", $id).to_string() $(, $($skip),+)?),
}.insert($db).await }.insert($db).await
}; };
} }
@@ -321,6 +404,9 @@ mod tests {
assert_collection!(&db, 3, Success; id); assert_collection!(&db, 3, Success; id);
assert_collection!(&db, 4, NotNullViolation; title); assert_collection!(&db, 4, NotNullViolation; title);
assert_collection!(&db, 5, NotNullViolation; overview); assert_collection!(&db, 5, NotNullViolation; overview);
assert_collection!(&db, 6, NotNullViolation; sort_title);
assert_collection!(&db, 7, NotNullViolation; fs_slug);
assert_collection!(&db, 8, NotNullViolation; web_slug);
} }
#[tokio::test] #[tokio::test]
@@ -351,6 +437,9 @@ mod tests {
tagline: notsettable!(tagline, concat!("M Tagline ", $id).to_string() $(, $($skip),+)?), tagline: notsettable!(tagline, concat!("M Tagline ", $id).to_string() $(, $($skip),+)?),
overview: notsettable!(overview, concat!("M Overview ", $id).to_string() $(, $($skip),+)?), overview: notsettable!(overview, concat!("M Overview ", $id).to_string() $(, $($skip),+)?),
date: notsettable!(date, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt") $(, $($skip),+)?), date: notsettable!(date, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt") $(, $($skip),+)?),
sort_title: notsettable!(sort_title, concat!("M Sort Title ", $id).to_string() $(, $($skip),+)?),
fs_slug: notsettable!(fs_slug, concat!("M FS Slug ", $id).to_string() $(, $($skip),+)?),
web_slug: notsettable!(web_slug, concat!("M Web Slug ", $id).to_string() $(, $($skip),+)?),
}.insert($db).await }.insert($db).await
}; };
} }
@@ -364,6 +453,9 @@ mod tests {
assert_movie!(&db, 5, NotNullViolation; tagline); assert_movie!(&db, 5, NotNullViolation; tagline);
assert_movie!(&db, 6, NotNullViolation; overview); assert_movie!(&db, 6, NotNullViolation; overview);
assert_movie!(&db, 7, NotNullViolation; date); assert_movie!(&db, 7, NotNullViolation; date);
assert_movie!(&db, 8, NotNullViolation; sort_title);
assert_movie!(&db, 9, NotNullViolation; fs_slug);
assert_movie!(&db, 10, NotNullViolation; web_slug);
} }
#[tokio::test] #[tokio::test]
@@ -397,6 +489,9 @@ mod tests {
tagline: notsettable!(tagline, concat!("S Tagline ", $id).to_string() $(, $($skip),+)?), tagline: notsettable!(tagline, concat!("S Tagline ", $id).to_string() $(, $($skip),+)?),
overview: notsettable!(overview, concat!("S Overview ", $id).to_string() $(, $($skip),+)?), overview: notsettable!(overview, concat!("S Overview ", $id).to_string() $(, $($skip),+)?),
date: notsettable!(date, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt") $(, $($skip),+)?), date: notsettable!(date, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt") $(, $($skip),+)?),
sort_title: notsettable!(sort_title, concat!("S Sort Title ", $id).to_string() $(, $($skip),+)?),
fs_slug: notsettable!(fs_slug, concat!("S FS Slug ", $id).to_string() $(, $($skip),+)?),
web_slug: notsettable!(web_slug, concat!("S Web Slug ", $id).to_string() $(, $($skip),+)?),
}.insert($db).await }.insert($db).await
}; };
} }
@@ -410,6 +505,9 @@ mod tests {
assert_show!(&db, 5, NotNullViolation; tagline); assert_show!(&db, 5, NotNullViolation; tagline);
assert_show!(&db, 6, NotNullViolation; overview); assert_show!(&db, 6, NotNullViolation; overview);
assert_show!(&db, 7, NotNullViolation; date); assert_show!(&db, 7, NotNullViolation; date);
assert_show!(&db, 8, NotNullViolation; sort_title);
assert_show!(&db, 9, NotNullViolation; fs_slug);
assert_show!(&db, 10, NotNullViolation; web_slug);
} }
#[tokio::test] #[tokio::test]
@@ -422,7 +520,7 @@ mod tests {
.expect("insert"); .expect("insert");
assert_eq!(model.show_id, ShowId::from_raw($show)); assert_eq!(model.show_id, ShowId::from_raw($show));
assert_eq!(model.season_number, $season); assert_eq!(model.season_number, ::flix_model::numbers::SeasonNumber::new($season));
assert_eq!(model.title, concat!("SS Title ", $show, ",", $season)); assert_eq!(model.title, concat!("SS Title ", $show, ",", $season));
assert_eq!(model.overview, concat!("SS Overview ", $show, ",", $season)); assert_eq!(model.overview, concat!("SS Overview ", $show, ",", $season));
assert_eq!(model.date, NaiveDate::from_yo_opt($show + $season, 1).expect("from_yo_opt")); assert_eq!(model.date, NaiveDate::from_yo_opt($show + $season, 1).expect("from_yo_opt"));
@@ -439,7 +537,7 @@ mod tests {
(@insert, $db:expr, $show:literal, $season:literal $(; $($skip:ident),+)?) => { (@insert, $db:expr, $show:literal, $season:literal $(; $($skip:ident),+)?) => {
super::seasons::ActiveModel { super::seasons::ActiveModel {
show_id: notsettable!(show_id, ShowId::from_raw($show) $(, $($skip),+)?), show_id: notsettable!(show_id, ShowId::from_raw($show) $(, $($skip),+)?),
season_number: notsettable!(season_number, $season $(, $($skip),+)?), season_number: notsettable!(season_number, ::flix_model::numbers::SeasonNumber::new($season) $(, $($skip),+)?),
title: notsettable!(title, concat!("SS Title ", $show, ",", $season).to_string() $(, $($skip),+)?), title: notsettable!(title, concat!("SS Title ", $show, ",", $season).to_string() $(, $($skip),+)?),
overview: notsettable!(overview, concat!("SS Overview ", $show, ",", $season).to_string() $(, $($skip),+)?), overview: notsettable!(overview, concat!("SS Overview ", $show, ",", $season).to_string() $(, $($skip),+)?),
date: notsettable!(date, NaiveDate::from_yo_opt($show + $season, 1).expect("from_yo_opt") $(, $($skip),+)?), date: notsettable!(date, NaiveDate::from_yo_opt($show + $season, 1).expect("from_yo_opt") $(, $($skip),+)?),
@@ -473,8 +571,8 @@ mod tests {
.expect("insert"); .expect("insert");
assert_eq!(model.show_id, ShowId::from_raw($show)); assert_eq!(model.show_id, ShowId::from_raw($show));
assert_eq!(model.season_number, $season); assert_eq!(model.season_number, ::flix_model::numbers::SeasonNumber::new($season));
assert_eq!(model.episode_number, $episode); assert_eq!(model.episode_number, ::flix_model::numbers::EpisodeNumber::new($episode));
assert_eq!(model.title, concat!("SSE Title ", $show, ",", $season, ",", $episode)); assert_eq!(model.title, concat!("SSE Title ", $show, ",", $season, ",", $episode));
assert_eq!(model.overview, concat!("SSE Overview ", $show, ",", $season, ",", $episode)); assert_eq!(model.overview, concat!("SSE Overview ", $show, ",", $season, ",", $episode));
assert_eq!(model.date, NaiveDate::from_yo_opt($show + $season, 1).expect("from_yo_opt")); assert_eq!(model.date, NaiveDate::from_yo_opt($show + $season, 1).expect("from_yo_opt"));
@@ -491,8 +589,8 @@ mod tests {
(@insert, $db:expr, $show:literal, $season:literal, $episode:literal $(; $($skip:ident),+)?) => { (@insert, $db:expr, $show:literal, $season:literal, $episode:literal $(; $($skip:ident),+)?) => {
super::episodes::ActiveModel { super::episodes::ActiveModel {
show_id: notsettable!(show_id, ShowId::from_raw($show) $(, $($skip),+)?), show_id: notsettable!(show_id, ShowId::from_raw($show) $(, $($skip),+)?),
season_number: notsettable!(season_number, $season $(, $($skip),+)?), season_number: notsettable!(season_number, ::flix_model::numbers::SeasonNumber::new($season) $(, $($skip),+)?),
episode_number: notsettable!(episode_number, $episode $(, $($skip),+)?), episode_number: notsettable!(episode_number, ::flix_model::numbers::EpisodeNumber::new($episode) $(, $($skip),+)?),
title: notsettable!(title, concat!("SSE Title ", $show, ",", $season, ",", $episode).to_string() $(, $($skip),+)?), title: notsettable!(title, concat!("SSE Title ", $show, ",", $season, ",", $episode).to_string() $(, $($skip),+)?),
overview: notsettable!(overview, concat!("SSE Overview ", $show, ",", $season, ",", $episode).to_string() $(, $($skip),+)?), overview: notsettable!(overview, concat!("SSE Overview ", $show, ",", $season, ",", $episode).to_string() $(, $($skip),+)?),
date: notsettable!(date, NaiveDate::from_yo_opt($show + $season, 1).expect("from_yo_opt") $(, $($skip),+)?), date: notsettable!(date, NaiveDate::from_yo_opt($show + $season, 1).expect("from_yo_opt") $(, $($skip),+)?),
File diff suppressed because it is too large Load Diff
+116 -68
View File
@@ -5,7 +5,7 @@ pub mod collections {
use flix_model::id::CollectionId as FlixId; use flix_model::id::CollectionId as FlixId;
use flix_tmdb::model::id::CollectionId; use flix_tmdb::model::id::CollectionId;
use chrono::NaiveDate; use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use crate::entity; use crate::entity;
@@ -16,22 +16,29 @@ pub mod collections {
#[sea_orm(table_name = "flix_tmdb_collections")] #[sea_orm(table_name = "flix_tmdb_collections")]
pub struct Model { pub struct Model {
/// The collection's TMDB ID /// The collection's TMDB ID
#[sea_orm(column_type = "Integer", primary_key, nullable, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub tmdb_id: CollectionId, pub tmdb_id: CollectionId,
/// The collection's ID /// The collection's ID
#[sea_orm(unique)] #[sea_orm(unique)]
pub flix_id: FlixId, pub flix_id: FlixId,
/// The date of the last update /// The date of the last update
pub last_update: NaiveDate, pub last_update: DateTime<Utc>,
/// The number of movies in the collection /// The number of movies in the collection
pub movie_count: u16, pub movie_count: u16,
/// Movies that are in this collection
#[sea_orm(has_many, on_update = "Cascade", on_delete = "Cascade")]
pub movies: HasMany<super::movies::Entity>,
/// The info for this collection /// The info for this collection
#[sea_orm(belongs_to, from = "flix_id", to = "id")] #[sea_orm(
belongs_to,
from = "flix_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub info: HasOne<entity::info::collections::Entity>, pub info: HasOne<entity::info::collections::Entity>,
/// Movies that are in this collection
#[sea_orm(has_many)]
pub movies: HasMany<super::movies::Entity>,
} }
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
@@ -44,7 +51,7 @@ pub mod movies {
use seamantic::model::duration::Seconds; use seamantic::model::duration::Seconds;
use chrono::NaiveDate; use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use crate::entity; use crate::entity;
@@ -55,24 +62,36 @@ pub mod movies {
#[sea_orm(table_name = "flix_tmdb_movies")] #[sea_orm(table_name = "flix_tmdb_movies")]
pub struct Model { pub struct Model {
/// The movie's TMDB ID /// The movie's TMDB ID
#[sea_orm(column_type = "Integer", primary_key, nullable, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub tmdb_id: MovieId, pub tmdb_id: MovieId,
/// The movie's ID /// The movie's ID
#[sea_orm(unique)] #[sea_orm(unique)]
pub flix_id: FlixId, pub flix_id: FlixId,
/// The date of the last update /// The date of the last update
pub last_update: NaiveDate, pub last_update: DateTime<Utc>,
/// The movie's runtime in seconds /// The movie's runtime in seconds
pub runtime: 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)] #[sea_orm(indexed)]
pub collection_id: Option<CollectionId>, pub collection_id: Option<CollectionId>,
/// The info for this collection /// The collection this movie belongs to
#[sea_orm(belongs_to, from = "collection_id", to = "tmdb_id")] #[sea_orm(
belongs_to,
from = "collection_id",
to = "tmdb_id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub collection: HasOne<super::collections::Entity>, pub collection: HasOne<super::collections::Entity>,
/// The info for this movie /// The info for this movie
#[sea_orm(belongs_to, from = "flix_id", to = "id")] #[sea_orm(
belongs_to,
from = "flix_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub info: HasOne<entity::info::movies::Entity>, pub info: HasOne<entity::info::movies::Entity>,
} }
@@ -84,7 +103,7 @@ pub mod shows {
use flix_model::id::ShowId as FlixId; use flix_model::id::ShowId as FlixId;
use flix_tmdb::model::id::ShowId; use flix_tmdb::model::id::ShowId;
use chrono::NaiveDate; use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use crate::entity; use crate::entity;
@@ -95,25 +114,32 @@ pub mod shows {
#[sea_orm(table_name = "flix_tmdb_shows")] #[sea_orm(table_name = "flix_tmdb_shows")]
pub struct Model { pub struct Model {
/// The show's TMDB ID /// The show's TMDB ID
#[sea_orm(column_type = "Integer", primary_key, nullable, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub tmdb_id: ShowId, pub tmdb_id: ShowId,
/// The show's ID /// The show's ID
#[sea_orm(unique)] #[sea_orm(unique)]
pub flix_id: FlixId, pub flix_id: FlixId,
/// The movie's runtime in seconds /// The movie's runtime in seconds
pub last_update: NaiveDate, pub last_update: DateTime<Utc>,
/// The number of seasons the show has /// The number of seasons the show has
pub number_of_seasons: u32, pub number_of_seasons: u32,
/// The info for this show
#[sea_orm(
belongs_to,
from = "flix_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
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, on_update = "Cascade", on_delete = "Cascade")] #[sea_orm(has_many)]
pub seasons: HasMany<super::seasons::Entity>, pub seasons: HasMany<super::seasons::Entity>,
/// Episodes that are part of this show /// Episodes that are part of this show
#[sea_orm(has_many, on_update = "Cascade", on_delete = "Cascade")] #[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>, pub episodes: HasMany<super::episodes::Entity>,
/// The info for this show
#[sea_orm(belongs_to, from = "flix_id", to = "id")]
pub info: HasOne<entity::info::shows::Entity>,
} }
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
@@ -125,7 +151,7 @@ pub mod seasons {
use flix_model::numbers::SeasonNumber; use flix_model::numbers::SeasonNumber;
use flix_tmdb::model::id::ShowId; use flix_tmdb::model::id::ShowId;
use chrono::NaiveDate; use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use crate::entity; use crate::entity;
@@ -148,18 +174,30 @@ pub mod seasons {
#[sea_orm(unique_key = "flix")] #[sea_orm(unique_key = "flix")]
pub flix_season: SeasonNumber, pub flix_season: SeasonNumber,
/// The date of the last update /// The date of the last update
pub last_update: NaiveDate, pub last_update: DateTime<Utc>,
/// The show this season belongs to /// The show this season belongs to
#[sea_orm(belongs_to, from = "tmdb_show", to = "tmdb_id")] #[sea_orm(
belongs_to,
from = "tmdb_show",
to = "tmdb_id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub show: HasOne<super::shows::Entity>, pub show: HasOne<super::shows::Entity>,
/// The info for this season /// The info for this season
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "(flix_show, flix_season)", from = "(flix_show, flix_season)",
to = "(show_id, season_number)" to = "(show_id, season_number)",
on_update = "Cascade",
on_delete = "Cascade"
)] )]
pub info: HasOne<entity::info::seasons::Entity>, pub info: HasOne<entity::info::seasons::Entity>,
/// Episodes that are part of this season
#[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>,
} }
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
@@ -172,7 +210,7 @@ pub mod episodes {
use flix_tmdb::model::id::ShowId; use flix_tmdb::model::id::ShowId;
use seamantic::model::duration::Seconds; use seamantic::model::duration::Seconds;
use chrono::NaiveDate; use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use crate::entity; use crate::entity;
@@ -201,25 +239,35 @@ pub mod episodes {
#[sea_orm(unique_key = "flix")] #[sea_orm(unique_key = "flix")]
pub flix_episode: EpisodeNumber, pub flix_episode: EpisodeNumber,
/// The date of the last update /// The date of the last update
pub last_update: NaiveDate, pub last_update: DateTime<Utc>,
/// The episode's runtime in seconds /// The episode's runtime in seconds
pub runtime: Seconds, pub runtime: Seconds,
/// The show this episode belongs to /// The show this episode belongs to
#[sea_orm(belongs_to, from = "tmdb_show", to = "tmdb_id")] #[sea_orm(
belongs_to,
from = "tmdb_show",
to = "tmdb_id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub show: HasOne<super::shows::Entity>, pub show: HasOne<super::shows::Entity>,
/// The season this episode belongs to /// The season this episode belongs to
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "(tmdb_show, tmdb_season)", from = "(tmdb_show, tmdb_season)",
to = "(tmdb_show, tmdb_season)" to = "(tmdb_show, tmdb_season)",
on_update = "Cascade",
on_delete = "Cascade"
)] )]
pub season: HasOne<super::seasons::Entity>, pub season: HasOne<super::seasons::Entity>,
/// The info for this episode /// The info for this episode
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "(flix_show, flix_season, flix_episode)", from = "(flix_show, flix_season, flix_episode)",
to = "(show_id, season_number, episode_number)" to = "(show_id, season_number, episode_number)",
on_update = "Cascade",
on_delete = "Cascade"
)] )]
pub info: HasOne<entity::info::episodes::Entity>, pub info: HasOne<entity::info::episodes::Entity>,
} }
@@ -231,11 +279,11 @@ pub mod episodes {
#[cfg(test)] #[cfg(test)]
pub mod test { pub mod test {
macro_rules! make_tmdb_collection { macro_rules! make_tmdb_collection {
($db:expr, $id:literal, $flix_id:literal) => { ($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)), tmdb_id: Set(::flix_tmdb::model::id::CollectionId::from_raw($id)),
flix_id: Set(::flix_model::id::CollectionId::from_raw($flix_id)), flix_id: Set(::flix_model::id::CollectionId::from_raw($flix_id)),
last_update: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")), last_update: Set(::chrono::Utc::now()),
movie_count: Set(::core::default::Default::default()), movie_count: Set(::core::default::Default::default()),
} }
.insert($db) .insert($db)
@@ -246,11 +294,11 @@ pub mod test {
pub(crate) use make_tmdb_collection; pub(crate) use make_tmdb_collection;
macro_rules! make_tmdb_movie { macro_rules! make_tmdb_movie {
($db:expr, $id:literal, $flix_id:literal) => { ($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)), tmdb_id: Set(::flix_tmdb::model::id::MovieId::from_raw($id)),
flix_id: Set(::flix_model::id::MovieId::from_raw($flix_id)), flix_id: Set(::flix_model::id::MovieId::from_raw($flix_id)),
last_update: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")), last_update: Set(::chrono::Utc::now()),
runtime: Set(::core::default::Default::default()), runtime: Set(::core::default::Default::default()),
collection_id: Set(None), collection_id: Set(None),
} }
@@ -262,11 +310,11 @@ pub mod test {
pub(crate) use make_tmdb_movie; pub(crate) use make_tmdb_movie;
macro_rules! make_tmdb_show { macro_rules! make_tmdb_show {
($db:expr, $id:literal, $flix_id:literal) => { ($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)), tmdb_id: Set(::flix_tmdb::model::id::ShowId::from_raw($id)),
flix_id: Set(::flix_model::id::ShowId::from_raw($flix_id)), flix_id: Set(::flix_model::id::ShowId::from_raw($flix_id)),
last_update: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")), last_update: Set(::chrono::Utc::now()),
number_of_seasons: Set(::core::default::Default::default()), number_of_seasons: Set(::core::default::Default::default()),
} }
.insert($db) .insert($db)
@@ -277,13 +325,13 @@ pub mod test {
pub(crate) use make_tmdb_show; pub(crate) use make_tmdb_show;
macro_rules! make_tmdb_season { macro_rules! make_tmdb_season {
($db:expr, $show:literal, $season:literal, $flix_show:literal, $flix_season:literal) => { ($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_show: Set(::flix_tmdb::model::id::ShowId::from_raw($show)),
tmdb_season: Set($season), tmdb_season: Set(::flix_model::numbers::SeasonNumber::new($season)),
flix_show: Set(::flix_model::id::ShowId::from_raw($flix_show)), flix_show: Set(::flix_model::id::ShowId::from_raw($flix_show)),
flix_season: Set($flix_season), flix_season: Set(::flix_model::numbers::SeasonNumber::new($flix_season)),
last_update: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")), last_update: Set(::chrono::Utc::now()),
} }
.insert($db) .insert($db)
.await .await
@@ -293,15 +341,15 @@ pub mod test {
pub(crate) use make_tmdb_season; pub(crate) use make_tmdb_season;
macro_rules! make_tmdb_episode { macro_rules! make_tmdb_episode {
($db:expr, $show:literal, $season:literal, $episode:literal, $flix_show:literal, $flix_season:literal, $flix_episode:literal) => { ($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_show: Set(::flix_tmdb::model::id::ShowId::from_raw($show)),
tmdb_season: Set($season), tmdb_season: Set(::flix_model::numbers::SeasonNumber::new($season)),
tmdb_episode: Set($episode), tmdb_episode: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
flix_show: Set(::flix_model::id::ShowId::from_raw($flix_show)), flix_show: Set(::flix_model::id::ShowId::from_raw($flix_show)),
flix_season: Set($flix_season), flix_season: Set(::flix_model::numbers::SeasonNumber::new($flix_season)),
flix_episode: Set($flix_episode), flix_episode: Set(::flix_model::numbers::EpisodeNumber::new($flix_episode)),
last_update: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")), last_update: Set(::chrono::Utc::now()),
runtime: Set(::core::default::Default::default()), runtime: Set(::core::default::Default::default()),
} }
.insert($db) .insert($db)
@@ -365,7 +413,7 @@ mod tests {
assert_eq!(model.tmdb_id, TmdbCollectionId::from_raw($tid)); assert_eq!(model.tmdb_id, TmdbCollectionId::from_raw($tid));
assert_eq!(model.flix_id, CollectionId::from_raw($id)); assert_eq!(model.flix_id, CollectionId::from_raw($id));
assert_eq!(model.last_update, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt")); assert_eq!(model.last_update, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc());
assert_eq!(model.movie_count, $id); assert_eq!(model.movie_count, $id);
}; };
($db:expr, $id:literal, $tid:literal, $error:ident $(; $($skip:ident),+)?) => { ($db:expr, $id:literal, $tid:literal, $error:ident $(; $($skip:ident),+)?) => {
@@ -378,7 +426,7 @@ mod tests {
super::collections::ActiveModel { super::collections::ActiveModel {
tmdb_id: notsettable!(tmdb_id, TmdbCollectionId::from_raw($tid) $(, $($skip),+)?), tmdb_id: notsettable!(tmdb_id, TmdbCollectionId::from_raw($tid) $(, $($skip),+)?),
flix_id: notsettable!(flix_id, CollectionId::from_raw($id) $(, $($skip),+)?), flix_id: notsettable!(flix_id, CollectionId::from_raw($id) $(, $($skip),+)?),
last_update: notsettable!(last_update, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt") $(, $($skip),+)?), last_update: notsettable!(last_update, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc() $(, $($skip),+)?),
movie_count: notsettable!(movie_count, $id $(, $($skip),+)?), movie_count: notsettable!(movie_count, $id $(, $($skip),+)?),
}.insert($db).await }.insert($db).await
}; };
@@ -412,7 +460,7 @@ mod tests {
assert_eq!(model.tmdb_id, TmdbMovieId::from_raw($tid)); assert_eq!(model.tmdb_id, TmdbMovieId::from_raw($tid));
assert_eq!(model.flix_id, MovieId::from_raw($id)); assert_eq!(model.flix_id, MovieId::from_raw($id));
assert_eq!(model.last_update, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt")); assert_eq!(model.last_update, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc());
assert_eq!(model.runtime, Duration::from_secs($tid).into()); assert_eq!(model.runtime, Duration::from_secs($tid).into());
assert_eq!(model.collection_id, $cid.map(TmdbCollectionId::from_raw)); assert_eq!(model.collection_id, $cid.map(TmdbCollectionId::from_raw));
}; };
@@ -426,7 +474,7 @@ mod tests {
super::movies::ActiveModel { super::movies::ActiveModel {
tmdb_id: notsettable!(tmdb_id, TmdbMovieId::from_raw($tid) $(, $($skip),+)?), tmdb_id: notsettable!(tmdb_id, TmdbMovieId::from_raw($tid) $(, $($skip),+)?),
flix_id: notsettable!(flix_id, MovieId::from_raw($id) $(, $($skip),+)?), flix_id: notsettable!(flix_id, MovieId::from_raw($id) $(, $($skip),+)?),
last_update: notsettable!(last_update, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt") $(, $($skip),+)?), last_update: notsettable!(last_update, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc() $(, $($skip),+)?),
runtime: notsettable!(runtime, Duration::from_secs($tid).into() $(, $($skip),+)?), runtime: notsettable!(runtime, Duration::from_secs($tid).into() $(, $($skip),+)?),
collection_id: notsettable!(collection_id, $cid.map(TmdbCollectionId::from_raw) $(, $($skip),+)?), collection_id: notsettable!(collection_id, $cid.map(TmdbCollectionId::from_raw) $(, $($skip),+)?),
}.insert($db).await }.insert($db).await
@@ -465,7 +513,7 @@ mod tests {
assert_eq!(model.tmdb_id, TmdbShowId::from_raw($tid)); assert_eq!(model.tmdb_id, TmdbShowId::from_raw($tid));
assert_eq!(model.flix_id, ShowId::from_raw($id)); assert_eq!(model.flix_id, ShowId::from_raw($id));
assert_eq!(model.last_update, NaiveDate::from_yo_opt(2000, $tid).expect("NaiveDate::from_yo_opt")); assert_eq!(model.last_update, NaiveDate::from_yo_opt($tid, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc());
assert_eq!(model.number_of_seasons, $id); assert_eq!(model.number_of_seasons, $id);
}; };
($db:expr, $id:literal, $tid:literal, $error:ident $(; $($skip:ident),+)?) => { ($db:expr, $id:literal, $tid:literal, $error:ident $(; $($skip:ident),+)?) => {
@@ -481,7 +529,7 @@ mod tests {
super::shows::ActiveModel { super::shows::ActiveModel {
tmdb_id: notsettable!(tmdb_id, TmdbShowId::from_raw($tid) $(, $($skip),+)?), tmdb_id: notsettable!(tmdb_id, TmdbShowId::from_raw($tid) $(, $($skip),+)?),
flix_id: notsettable!(flix_id, ShowId::from_raw($id) $(, $($skip),+)?), flix_id: notsettable!(flix_id, ShowId::from_raw($id) $(, $($skip),+)?),
last_update: notsettable!(last_update, NaiveDate::from_yo_opt(2000, $tid).expect("NaiveDate::from_yo_opt") $(, $($skip),+)?), last_update: notsettable!(last_update, NaiveDate::from_yo_opt($tid, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc() $(, $($skip),+)?),
number_of_seasons: notsettable!(number_of_seasons, $id $(, $($skip),+)?), number_of_seasons: notsettable!(number_of_seasons, $id $(, $($skip),+)?),
}.insert($db).await }.insert($db).await
}; };
@@ -514,10 +562,10 @@ mod tests {
.expect("insert"); .expect("insert");
assert_eq!(model.tmdb_show, TmdbShowId::from_raw($tshow)); assert_eq!(model.tmdb_show, TmdbShowId::from_raw($tshow));
assert_eq!(model.tmdb_season, $tseason); assert_eq!(model.tmdb_season, ::flix_model::numbers::SeasonNumber::new($tseason));
assert_eq!(model.flix_show, ShowId::from_raw($show)); assert_eq!(model.flix_show, ShowId::from_raw($show));
assert_eq!(model.flix_season, $season); assert_eq!(model.flix_season, ::flix_model::numbers::SeasonNumber::new($season));
assert_eq!(model.last_update, NaiveDate::from_yo_opt(2000, $tshow).expect("NaiveDate::from_yo_opt")); assert_eq!(model.last_update, NaiveDate::from_yo_opt($tshow, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc());
}; };
($db:expr, $show:literal, $season:literal, $tshow:literal, $tseason:literal, $error:ident $(; $($skip:ident),+)?) => { ($db:expr, $show:literal, $season:literal, $tshow:literal, $tseason:literal, $error:ident $(; $($skip:ident),+)?) => {
let model = assert_season!(@insert, $db, $show, $season, $tshow, $tseason $(; $($skip),+)?) let model = assert_season!(@insert, $db, $show, $season, $tshow, $tseason $(; $($skip),+)?)
@@ -531,10 +579,10 @@ mod tests {
(@insert, $db:expr, $show:literal, $season:literal, $tshow:literal, $tseason:literal $(; $($skip:ident),+)?) => { (@insert, $db:expr, $show:literal, $season:literal, $tshow:literal, $tseason:literal $(; $($skip:ident),+)?) => {
super::seasons::ActiveModel { super::seasons::ActiveModel {
tmdb_show: notsettable!(tmdb_show, TmdbShowId::from_raw($tshow) $(, $($skip),+)?), tmdb_show: notsettable!(tmdb_show, TmdbShowId::from_raw($tshow) $(, $($skip),+)?),
tmdb_season: notsettable!(tmdb_season, $tseason $(, $($skip),+)?), tmdb_season: notsettable!(tmdb_season, ::flix_model::numbers::SeasonNumber::new($tseason) $(, $($skip),+)?),
flix_show: notsettable!(flix_show, ShowId::from_raw($show) $(, $($skip),+)?), flix_show: notsettable!(flix_show, ShowId::from_raw($show) $(, $($skip),+)?),
flix_season: notsettable!(flix_season, $season $(, $($skip),+)?), flix_season: notsettable!(flix_season, ::flix_model::numbers::SeasonNumber::new($season) $(, $($skip),+)?),
last_update: notsettable!(last_update, NaiveDate::from_yo_opt(2000, $tshow).expect("NaiveDate::from_yo_opt") $(, $($skip),+)?), last_update: notsettable!(last_update, NaiveDate::from_yo_opt($tshow, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc() $(, $($skip),+)?),
}.insert($db).await }.insert($db).await
}; };
} }
@@ -569,12 +617,12 @@ mod tests {
.expect("insert"); .expect("insert");
assert_eq!(model.tmdb_show, TmdbShowId::from_raw($tshow)); assert_eq!(model.tmdb_show, TmdbShowId::from_raw($tshow));
assert_eq!(model.tmdb_season, $tseason); assert_eq!(model.tmdb_season, ::flix_model::numbers::SeasonNumber::new($tseason));
assert_eq!(model.tmdb_episode, $tepisode); assert_eq!(model.tmdb_episode, ::flix_model::numbers::EpisodeNumber::new($tepisode));
assert_eq!(model.flix_show, ShowId::from_raw($show)); assert_eq!(model.flix_show, ShowId::from_raw($show));
assert_eq!(model.flix_season, $season); assert_eq!(model.flix_season, ::flix_model::numbers::SeasonNumber::new($season));
assert_eq!(model.flix_episode, $episode); assert_eq!(model.flix_episode, ::flix_model::numbers::EpisodeNumber::new($episode));
assert_eq!(model.last_update, NaiveDate::from_yo_opt(2000, $tshow).expect("NaiveDate::from_yo_opt")); assert_eq!(model.last_update, NaiveDate::from_yo_opt($tshow, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc());
assert_eq!(model.runtime, Duration::from_secs($tshow).into()); assert_eq!(model.runtime, Duration::from_secs($tshow).into());
}; };
($db:expr, $show:literal, $season:literal, $episode:literal, $tshow:literal, $tseason:literal, $tepisode:literal, $error:ident $(; $($skip:ident),+)?) => { ($db:expr, $show:literal, $season:literal, $episode:literal, $tshow:literal, $tseason:literal, $tepisode:literal, $error:ident $(; $($skip:ident),+)?) => {
@@ -589,12 +637,12 @@ mod tests {
(@insert, $db:expr, $show:literal, $season:literal, $episode:literal, $tshow:literal, $tseason:literal, $tepisode:literal $(; $($skip:ident),+)?) => { (@insert, $db:expr, $show:literal, $season:literal, $episode:literal, $tshow:literal, $tseason:literal, $tepisode:literal $(; $($skip:ident),+)?) => {
super::episodes::ActiveModel { super::episodes::ActiveModel {
tmdb_show: notsettable!(tmdb_show, TmdbShowId::from_raw($tshow) $(, $($skip),+)?), tmdb_show: notsettable!(tmdb_show, TmdbShowId::from_raw($tshow) $(, $($skip),+)?),
tmdb_season: notsettable!(tmdb_season, $tseason $(, $($skip),+)?), tmdb_season: notsettable!(tmdb_season, ::flix_model::numbers::SeasonNumber::new($tseason) $(, $($skip),+)?),
tmdb_episode: notsettable!(tmdb_episode, $tepisode $(, $($skip),+)?), tmdb_episode: notsettable!(tmdb_episode, ::flix_model::numbers::EpisodeNumber::new($tepisode) $(, $($skip),+)?),
flix_show: notsettable!(flix_show, ShowId::from_raw($show) $(, $($skip),+)?), flix_show: notsettable!(flix_show, ShowId::from_raw($show) $(, $($skip),+)?),
flix_season: notsettable!(flix_season, $season $(, $($skip),+)?), flix_season: notsettable!(flix_season, ::flix_model::numbers::SeasonNumber::new($season) $(, $($skip),+)?),
flix_episode: notsettable!(flix_episode, $episode $(, $($skip),+)?), flix_episode: notsettable!(flix_episode, ::flix_model::numbers::EpisodeNumber::new($episode) $(, $($skip),+)?),
last_update: notsettable!(last_update, NaiveDate::from_yo_opt(2000, $tshow).expect("NaiveDate::from_yo_opt") $(, $($skip),+)?), last_update: notsettable!(last_update, NaiveDate::from_yo_opt($tshow, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc() $(, $($skip),+)?),
runtime: notsettable!(runtime, Duration::from_secs($tshow).into() $(, $($skip),+)?), runtime: notsettable!(runtime, Duration::from_secs($tshow).into() $(, $($skip),+)?),
}.insert($db).await }.insert($db).await
}; };
+53 -29
View File
@@ -4,7 +4,7 @@
pub mod collections { pub mod collections {
use flix_model::id::{CollectionId, RawId}; use flix_model::id::{CollectionId, RawId};
use chrono::NaiveDate; use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use crate::entity; use crate::entity;
@@ -21,10 +21,17 @@ pub mod collections {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub user_id: RawId, pub user_id: RawId,
/// The date this collection was watched /// The date this collection was watched
pub watched_date: NaiveDate, pub watched_date: DateTime<Utc>,
/// The info for this collection /// The info for this collection
#[sea_orm(belongs_to, relation_enum = "Info", from = "id", to = "id")] #[sea_orm(
belongs_to,
relation_enum = "Info",
from = "id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub info: HasOne<entity::info::collections::Entity>, 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)] #[sea_orm(belongs_to, relation_enum = "Content", from = "id", to = "id", skip_fk)]
@@ -38,7 +45,7 @@ pub mod collections {
pub mod movies { pub mod movies {
use flix_model::id::{MovieId, RawId}; use flix_model::id::{MovieId, RawId};
use chrono::NaiveDate; use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use crate::entity; use crate::entity;
@@ -55,10 +62,16 @@ pub mod movies {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub user_id: RawId, pub user_id: RawId,
/// The date this movie was watched /// The date this movie was watched
pub watched_date: NaiveDate, pub watched_date: DateTime<Utc>,
/// The info for this movie /// The info for this movie
#[sea_orm(belongs_to, from = "id", to = "id")] #[sea_orm(
belongs_to,
from = "id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub info: HasOne<entity::info::movies::Entity>, 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)] #[sea_orm(belongs_to, relation_enum = "Content", from = "id", to = "id", skip_fk)]
@@ -72,7 +85,7 @@ pub mod movies {
pub mod shows { pub mod shows {
use flix_model::id::{RawId, ShowId}; use flix_model::id::{RawId, ShowId};
use chrono::NaiveDate; use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use crate::entity; use crate::entity;
@@ -89,10 +102,17 @@ pub mod shows {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub user_id: RawId, pub user_id: RawId,
/// The date this show was watched /// The date this show was watched
pub watched_date: NaiveDate, pub watched_date: DateTime<Utc>,
/// The info for this show /// The info for this show
#[sea_orm(belongs_to, relation_enum = "Info", from = "id", to = "id")] #[sea_orm(
belongs_to,
relation_enum = "Info",
from = "id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub info: HasOne<entity::info::shows::Entity>, 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)] #[sea_orm(belongs_to, relation_enum = "Content", from = "id", to = "id", skip_fk)]
@@ -107,7 +127,7 @@ pub mod seasons {
use flix_model::id::{RawId, ShowId}; use flix_model::id::{RawId, ShowId};
use flix_model::numbers::SeasonNumber; use flix_model::numbers::SeasonNumber;
use chrono::NaiveDate; use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use crate::entity; use crate::entity;
@@ -127,14 +147,16 @@ pub mod seasons {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub user_id: RawId, pub user_id: RawId,
/// The date this season was watched /// The date this season was watched
pub watched_date: NaiveDate, pub watched_date: DateTime<Utc>,
/// The info for this season /// The info for this season
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
relation_enum = "Info", relation_enum = "Info",
from = "(show_id, season_number)", from = "(show_id, season_number)",
to = "(show_id, season_number)" to = "(show_id, season_number)",
on_update = "Cascade",
on_delete = "Cascade"
)] )]
pub info: HasOne<entity::info::seasons::Entity>, pub info: HasOne<entity::info::seasons::Entity>,
/// The content for this season /// The content for this season
@@ -156,7 +178,7 @@ pub mod episodes {
use flix_model::id::{RawId, ShowId}; use flix_model::id::{RawId, ShowId};
use flix_model::numbers::{EpisodeNumber, SeasonNumber}; use flix_model::numbers::{EpisodeNumber, SeasonNumber};
use chrono::NaiveDate; use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use crate::entity; use crate::entity;
@@ -179,14 +201,16 @@ pub mod episodes {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub user_id: RawId, pub user_id: RawId,
/// The date this episode was watched /// The date this episode was watched
pub watched_date: NaiveDate, pub watched_date: DateTime<Utc>,
/// The info for this episode /// The info for this episode
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
relation_enum = "Info", relation_enum = "Info",
from = "(show_id, season_number, episode_number)", from = "(show_id, season_number, episode_number)",
to = "(show_id, season_number, episode_number)" to = "(show_id, season_number, episode_number)",
on_update = "Cascade",
on_delete = "Cascade"
)] )]
pub info: HasOne<entity::info::episodes::Entity>, pub info: HasOne<entity::info::episodes::Entity>,
/// The content for this episode /// The content for this episode
@@ -207,11 +231,11 @@ pub mod episodes {
#[cfg(test)] #[cfg(test)]
pub mod test { pub mod test {
macro_rules! make_watched_movie { macro_rules! make_watched_movie {
($db:expr, $id:literal, $user:literal) => { ($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)), id: Set(::flix_model::id::MovieId::from_raw($id)),
user_id: Set($user), user_id: Set($user),
watched_date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")), watched_date: Set(::chrono::Utc::now()),
} }
.insert($db) .insert($db)
.await .await
@@ -221,13 +245,13 @@ pub mod test {
pub(crate) use make_watched_movie; pub(crate) use make_watched_movie;
macro_rules! make_watched_episode { macro_rules! make_watched_episode {
($db:expr, $show:literal, $season:literal, $episode:literal, $user:literal) => { ($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)), show_id: Set(::flix_model::id::ShowId::from_raw($show)),
season_number: Set($season), season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
episode_number: Set($episode), episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
user_id: Set($user), user_id: Set($user),
watched_date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")), watched_date: Set(::chrono::Utc::now()),
} }
.insert($db) .insert($db)
.await .await
@@ -284,7 +308,7 @@ mod tests {
assert_eq!(model.id, MovieId::from_raw($id)); assert_eq!(model.id, MovieId::from_raw($id));
assert_eq!(model.user_id, $uid); assert_eq!(model.user_id, $uid);
assert_eq!(model.watched_date, NaiveDate::from_yo_opt($uid, 1).expect("from_yo_opt")); assert_eq!(model.watched_date, NaiveDate::from_yo_opt($uid, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc());
}; };
($db:expr, $id:literal, $uid:literal, $error:ident $(; $($skip:ident),+)?) => { ($db:expr, $id:literal, $uid:literal, $error:ident $(; $($skip:ident),+)?) => {
let model = assert_movie!(@insert, $db, $id, $uid $(; $($skip),+)?) let model = assert_movie!(@insert, $db, $id, $uid $(; $($skip),+)?)
@@ -296,7 +320,7 @@ mod tests {
super::movies::ActiveModel { super::movies::ActiveModel {
id: notsettable!(id, MovieId::from_raw($id) $(, $($skip),+)?), id: notsettable!(id, MovieId::from_raw($id) $(, $($skip),+)?),
user_id: notsettable!(user_id, $uid $(, $($skip),+)?), user_id: notsettable!(user_id, $uid $(, $($skip),+)?),
watched_date: notsettable!(watched_date, NaiveDate::from_yo_opt($uid, 1).expect("from_yo_opt") $(, $($skip),+)?), watched_date: notsettable!(watched_date, NaiveDate::from_yo_opt($uid, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc() $(, $($skip),+)?),
}.insert($db).await }.insert($db).await
}; };
} }
@@ -326,10 +350,10 @@ mod tests {
.expect("insert"); .expect("insert");
assert_eq!(model.show_id, ShowId::from_raw($show)); assert_eq!(model.show_id, ShowId::from_raw($show));
assert_eq!(model.season_number, $season); assert_eq!(model.season_number, ::flix_model::numbers::SeasonNumber::new($season));
assert_eq!(model.episode_number, $episode); assert_eq!(model.episode_number, ::flix_model::numbers::EpisodeNumber::new($episode));
assert_eq!(model.user_id, $uid); assert_eq!(model.user_id, $uid);
assert_eq!(model.watched_date, NaiveDate::from_yo_opt($uid, 1).expect("from_yo_opt")); assert_eq!(model.watched_date, NaiveDate::from_yo_opt($uid, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc());
}; };
($db:expr, $show:literal, $season:literal, $episode:literal, $uid:literal, $error:ident $(; $($skip:ident),+)?) => { ($db:expr, $show:literal, $season:literal, $episode:literal, $uid:literal, $error:ident $(; $($skip:ident),+)?) => {
let model = assert_episode!(@insert, $db, $show, $season, $episode, $uid $(; $($skip),+)?) let model = assert_episode!(@insert, $db, $show, $season, $episode, $uid $(; $($skip),+)?)
@@ -340,10 +364,10 @@ mod tests {
(@insert, $db:expr, $show:literal, $season:literal, $episode:literal, $uid:literal $(; $($skip:ident),+)?) => { (@insert, $db:expr, $show:literal, $season:literal, $episode:literal, $uid:literal $(; $($skip:ident),+)?) => {
super::episodes::ActiveModel { super::episodes::ActiveModel {
show_id: notsettable!(show_id, ShowId::from_raw($show) $(, $($skip),+)?), show_id: notsettable!(show_id, ShowId::from_raw($show) $(, $($skip),+)?),
season_number: notsettable!(season_number, $season $(, $($skip),+)?), season_number: notsettable!(season_number, ::flix_model::numbers::SeasonNumber::new($season) $(, $($skip),+)?),
episode_number: notsettable!(episode_number, $episode $(, $($skip),+)?), episode_number: notsettable!(episode_number, ::flix_model::numbers::EpisodeNumber::new($episode) $(, $($skip),+)?),
user_id: notsettable!(user_id, $uid $(, $($skip),+)?), user_id: notsettable!(user_id, $uid $(, $($skip),+)?),
watched_date: notsettable!(watched_date, NaiveDate::from_yo_opt($uid, 1).expect("from_yo_opt") $(, $($skip),+)?), watched_date: notsettable!(watched_date, NaiveDate::from_yo_opt($uid, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc() $(, $($skip),+)?),
}.insert($db).await }.insert($db).await
}; };
} }
+12 -13
View File
@@ -1,32 +1,31 @@
[package] [package]
name = "flix" name = "flix"
version = "0.0.13" version = "0.0.19"
license-file.workspace = true
categories = []
description = "Mechanisms for interacting with flix media" description = "Mechanisms for interacting with flix media"
repository = "https://github.com/QuantumShade/flix" repository = "https://github.com/QuantumShade/flix"
categories = []
authors.workspace = true
edition.workspace = true edition.workspace = true
license-file.workspace = true
rust-version.workspace = true rust-version.workspace = true
[package.metadata.docs.rs] [package.metadata.docs.rs]
all-features = true all-features = true
rustdoc-args = ["--cfg", "docsrs"] rustdoc-args = ["--cfg", "docsrs"]
[lints]
workspace = true
[features]
default = []
fs = ["dep:flix-fs"]
serde = ["flix-model/serde"]
tmdb = ["dep:flix-tmdb", "flix-db/tmdb"]
[dependencies] [dependencies]
flix-db = { workspace = true } flix-db = { workspace = true }
flix-model = { workspace = true } flix-model = { workspace = true }
flix-fs = { workspace = true, optional = true } flix-fs = { workspace = true, optional = true }
flix-tmdb = { workspace = true, optional = true } flix-tmdb = { workspace = true, optional = true }
[features]
default = []
fs = ["dep:flix-fs"]
serde = ["flix-model/serde"]
tmdb = ["dep:flix-tmdb", "flix-db/tmdb"]
[lints]
workspace = true
+9 -10
View File
@@ -1,28 +1,27 @@
[package] [package]
name = "flix-fs" name = "flix-fs"
version = "0.0.13" version = "0.0.19"
license-file.workspace = true
categories = []
description = "Filesystem scanner for flix media" description = "Filesystem scanner for flix media"
repository = "https://github.com/QuantumShade/flix" repository = "https://github.com/QuantumShade/flix"
categories = []
authors.workspace = true
edition.workspace = true edition.workspace = true
license-file.workspace = true
rust-version.workspace = true rust-version.workspace = true
[package.metadata.docs.rs] [package.metadata.docs.rs]
all-features = true all-features = true
rustdoc-args = ["--cfg", "docsrs"] rustdoc-args = ["--cfg", "docsrs"]
[lints]
workspace = true
[dependencies] [dependencies]
flix-model = { workspace = true }
async-stream = { workspace = true } async-stream = { workspace = true }
regex = { workspace = true, features = ["std", "perf"] } either = { workspace = true }
flix-model = { workspace = true }
regex = { workspace = true, features = ["perf", "std"] }
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tokio-stream = { workspace = true, features = ["fs"] } tokio-stream = { workspace = true, features = ["fs"] }
[lints]
workspace = true
+32 -155
View File
@@ -4,8 +4,7 @@ use core::pin::Pin;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::path::Path; use std::path::Path;
use flix_model::id::{CollectionId, MovieId, ShowId}; use flix_model::id::CollectionId;
use flix_model::numbers::{EpisodeNumbers, SeasonNumber};
use async_stream::stream; use async_stream::stream;
use tokio::fs; use tokio::fs;
@@ -14,7 +13,9 @@ use tokio_stream::wrappers::ReadDirStream;
use crate::Error; use crate::Error;
use crate::macros::is_image_extension; use crate::macros::is_image_extension;
use crate::scanner::{generic, movie, show}; use crate::scanner::{
CollectionScan, EpisodeScan, MediaRef, MovieScan, SeasonScan, ShowScan, generic, movie, show,
};
/// A collection item /// A collection item
pub type Item = crate::Item<Scanner>; pub type Item = crate::Item<Scanner>;
@@ -22,74 +23,21 @@ pub type Item = crate::Item<Scanner>;
/// The scanner for collections /// The scanner for collections
pub enum Scanner { pub enum Scanner {
/// A scanned collection /// A scanned collection
Collection { Collection(CollectionScan),
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the collection
id: CollectionId,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned movie /// A scanned movie
Movie { Movie(MovieScan),
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the movie
id: MovieId,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned show /// A scanned show
Show { Show(ShowScan),
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the show
id: ShowId,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned episode /// A scanned episode
Season { Season(SeasonScan),
/// The ID of the show this season belongs to
show: ShowId,
/// The number of this season
season: SeasonNumber,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned episode /// A scanned episode
Episode { Episode(EpisodeScan),
/// The ID of the show this episode belongs to
show: ShowId,
/// The season this episode belongs to
season: SeasonNumber,
/// The number(s) of this episode
episode: EpisodeNumbers,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
} }
impl From<movie::Scanner> for Scanner { impl From<movie::Scanner> for Scanner {
fn from(value: movie::Scanner) -> Self { fn from(value: movie::Scanner) -> Self {
match value { match value {
movie::Scanner::Movie { movie::Scanner::Movie(m) => Self::Movie(m),
parent,
id,
media_file_name,
poster_file_name,
} => Self::Movie {
parent,
id,
media_file_name,
poster_file_name,
},
} }
} }
} }
@@ -97,37 +45,9 @@ impl From<movie::Scanner> for Scanner {
impl From<show::Scanner> for Scanner { impl From<show::Scanner> for Scanner {
fn from(value: show::Scanner) -> Self { fn from(value: show::Scanner) -> Self {
match value { match value {
show::Scanner::Show { show::Scanner::Show(s) => Self::Show(s),
parent, show::Scanner::Season(s) => Self::Season(s),
id, show::Scanner::Episode(e) => Self::Episode(e),
poster_file_name,
} => Self::Show {
parent,
id,
poster_file_name,
},
show::Scanner::Season {
show,
season,
poster_file_name,
} => Self::Season {
show,
season,
poster_file_name,
},
show::Scanner::Episode {
show,
season,
episode,
media_file_name,
poster_file_name,
} => Self::Episode {
show,
season,
episode,
media_file_name,
poster_file_name,
},
} }
} }
} }
@@ -135,57 +55,11 @@ impl From<show::Scanner> for Scanner {
impl From<generic::Scanner> for Scanner { impl From<generic::Scanner> for Scanner {
fn from(value: generic::Scanner) -> Self { fn from(value: generic::Scanner) -> Self {
match value { match value {
generic::Scanner::Collection { generic::Scanner::Collection(c) => Self::Collection(c),
parent, generic::Scanner::Movie(m) => Self::Movie(m),
id, generic::Scanner::Show(s) => Self::Show(s),
poster_file_name, generic::Scanner::Season(s) => Self::Season(s),
} => Self::Collection { generic::Scanner::Episode(e) => Self::Episode(e),
parent,
id,
poster_file_name,
},
generic::Scanner::Movie {
parent,
id,
media_file_name,
poster_file_name,
} => Self::Movie {
parent,
id,
media_file_name,
poster_file_name,
},
generic::Scanner::Show {
parent,
id,
poster_file_name,
} => Self::Show {
parent,
id,
poster_file_name,
},
generic::Scanner::Season {
show,
season,
poster_file_name,
} => Self::Season {
show,
season,
poster_file_name,
},
generic::Scanner::Episode {
show,
season,
episode,
media_file_name,
poster_file_name,
} => Self::Episode {
show,
season,
episode,
media_file_name,
poster_file_name,
},
} }
} }
} }
@@ -194,8 +68,8 @@ impl Scanner {
/// Scan a folder for a collection /// Scan a folder for a collection
pub fn scan_collection( pub fn scan_collection(
path: &Path, path: &Path,
parent: Option<CollectionId>, parent_ref: Option<MediaRef<CollectionId>>,
id: CollectionId, id_ref: MediaRef<CollectionId>,
) -> Pin<Box<impl Stream<Item = Item>>> { ) -> Pin<Box<impl Stream<Item = Item>>> {
Box::pin(stream!({ Box::pin(stream!({
let dirs = match fs::read_dir(path).await { let dirs = match fs::read_dir(path).await {
@@ -215,18 +89,19 @@ impl Scanner {
for await dir in ReadDirStream::new(dirs) { for await dir in ReadDirStream::new(dirs) {
match dir { match dir {
Ok(dir) => { Ok(dir) => {
let path = dir.path();
let filetype = match dir.file_type().await { let filetype = match dir.file_type().await {
Ok(filetype) => filetype, Ok(filetype) => filetype,
Err(err) => { Err(err) => {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::FileType(err)), event: Err(Error::FileType(err)),
}; };
continue; continue;
} }
}; };
let path = dir.path();
if filetype.is_dir() { if filetype.is_dir() {
subdirs_to_scan.push(path); subdirs_to_scan.push(path);
continue; continue;
@@ -236,7 +111,7 @@ impl Scanner {
is_image_extension!() => { is_image_extension!() => {
if poster_file_name.is_some() { if poster_file_name.is_some() {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::DuplicatePosterFile), event: Err(Error::DuplicatePosterFile),
}; };
continue; continue;
@@ -248,7 +123,7 @@ impl Scanner {
} }
Some(_) | None => { Some(_) | None => {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::UnexpectedFile), event: Err(Error::UnexpectedFile),
}; };
} }
@@ -265,15 +140,17 @@ impl Scanner {
yield Item { yield Item {
path: path.to_owned(), path: path.to_owned(),
event: Ok(Self::Collection { event: Ok(Self::Collection(CollectionScan {
parent, parent_ref,
id, id_ref: id_ref.clone(),
poster_file_name, poster_file_name,
}), })),
}; };
for subdir in subdirs_to_scan { for subdir in subdirs_to_scan {
for await event in generic::Scanner::scan_detect_folder(&subdir, Some(id)) { for await event in
generic::Scanner::scan_detect_folder(&subdir, Some(id_ref.clone()))
{
yield event.map(|e| e.into()); yield event.map(|e| e.into());
} }
} }
+13 -22
View File
@@ -13,6 +13,7 @@ use tokio_stream::wrappers::ReadDirStream;
use crate::Error; use crate::Error;
use crate::macros::{is_image_extension, is_media_extension}; 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>; pub type Item = crate::Item<Scanner>;
@@ -20,25 +21,14 @@ pub type Item = crate::Item<Scanner>;
/// The scanner for epispdes /// The scanner for epispdes
pub enum Scanner { pub enum Scanner {
/// A scanned episode /// A scanned episode
Episode { Episode(EpisodeScan),
/// The ID of the show this episode belongs to
show: ShowId,
/// The season this episode belongs to
season: SeasonNumber,
/// The number(s) of this episode
episode: EpisodeNumbers,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
} }
impl Scanner { impl Scanner {
/// Scan a folder for an episode /// Scan a folder for an episode
pub fn scan_episode( pub fn scan_episode(
path: &Path, path: &Path,
show: ShowId, show_ref: MediaRef<ShowId>,
season: SeasonNumber, season: SeasonNumber,
episode: EpisodeNumbers, episode: EpisodeNumbers,
) -> impl Stream<Item = Item> { ) -> impl Stream<Item = Item> {
@@ -60,11 +50,13 @@ impl Scanner {
for await dir in ReadDirStream::new(dirs) { for await dir in ReadDirStream::new(dirs) {
match dir { match dir {
Ok(dir) => { Ok(dir) => {
let path = dir.path();
let filetype = match dir.file_type().await { let filetype = match dir.file_type().await {
Ok(filetype) => filetype, Ok(filetype) => filetype,
Err(err) => { Err(err) => {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::FileType(err)), event: Err(Error::FileType(err)),
}; };
continue; continue;
@@ -72,18 +64,17 @@ impl Scanner {
}; };
if !filetype.is_file() { if !filetype.is_file() {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::UnexpectedNonFile), event: Err(Error::UnexpectedNonFile),
}; };
continue; continue;
} }
let path = dir.path();
match path.extension().and_then(OsStr::to_str) { match path.extension().and_then(OsStr::to_str) {
is_media_extension!() => { is_media_extension!() => {
if media_file_name.is_some() { if media_file_name.is_some() {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::DuplicateMediaFile), event: Err(Error::DuplicateMediaFile),
}; };
continue; continue;
@@ -97,7 +88,7 @@ impl Scanner {
is_image_extension!() => { is_image_extension!() => {
if poster_file_name.is_some() { if poster_file_name.is_some() {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::DuplicatePosterFile), event: Err(Error::DuplicatePosterFile),
}; };
continue; continue;
@@ -109,7 +100,7 @@ impl Scanner {
} }
Some(_) | None => { Some(_) | None => {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::UnexpectedFile), event: Err(Error::UnexpectedFile),
}; };
} }
@@ -134,13 +125,13 @@ impl Scanner {
yield Item { yield Item {
path: path.to_owned(), path: path.to_owned(),
event: Ok(Self::Episode { event: Ok(Self::Episode(EpisodeScan {
show, show_ref,
season, season,
episode, episode,
media_file_name, media_file_name,
poster_file_name, poster_file_name,
}), })),
}; };
}) })
} }
+67 -167
View File
@@ -6,16 +6,18 @@ use std::path::Path;
use std::sync::OnceLock; use std::sync::OnceLock;
use flix_model::id::{CollectionId, MovieId, RawId, ShowId}; use flix_model::id::{CollectionId, MovieId, RawId, ShowId};
use flix_model::numbers::{EpisodeNumbers, SeasonNumber};
use async_stream::stream; use async_stream::stream;
use either::Either;
use regex::Regex; use regex::Regex;
use tokio::fs; use tokio::fs;
use tokio_stream::Stream; use tokio_stream::Stream;
use tokio_stream::wrappers::ReadDirStream; use tokio_stream::wrappers::ReadDirStream;
use crate::Error; use crate::Error;
use crate::scanner::{collection, movie, show}; use crate::scanner::{
CollectionScan, EpisodeScan, MediaRef, MovieScan, SeasonScan, ShowScan, collection, movie, show,
};
static MEDIA_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new(); static MEDIA_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
static SEASON_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new(); static SEASON_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
@@ -24,116 +26,28 @@ static SEASON_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
pub type Item = crate::Item<Scanner>; pub type Item = crate::Item<Scanner>;
/// The scanner for collections /// The scanner for collections
#[derive(Debug)]
pub enum Scanner { pub enum Scanner {
/// A scanned collection /// A scanned collection
Collection { Collection(CollectionScan),
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the collection
id: CollectionId,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned movie /// A scanned movie
Movie { Movie(MovieScan),
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the movie
id: MovieId,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned show /// A scanned show
Show { Show(ShowScan),
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the show
id: ShowId,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned episode /// A scanned episode
Season { Season(SeasonScan),
/// The ID of the show this season belongs to
show: ShowId,
/// The season this episode belongs to
season: SeasonNumber,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned episode /// A scanned episode
Episode { Episode(EpisodeScan),
/// The ID of the show this episode belongs to
show: ShowId,
/// The season this episode belongs to
season: SeasonNumber,
/// The number(s) of this episode
episode: EpisodeNumbers,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
} }
impl From<collection::Scanner> for Scanner { impl From<collection::Scanner> for Scanner {
fn from(value: collection::Scanner) -> Self { fn from(value: collection::Scanner) -> Self {
match value { match value {
collection::Scanner::Collection { collection::Scanner::Collection(c) => Self::Collection(c),
parent, collection::Scanner::Movie(m) => Self::Movie(m),
id, collection::Scanner::Show(s) => Self::Show(s),
poster_file_name, collection::Scanner::Season(s) => Self::Season(s),
} => Self::Collection { collection::Scanner::Episode(e) => Self::Episode(e),
parent,
id,
poster_file_name,
},
collection::Scanner::Movie {
parent,
id,
media_file_name,
poster_file_name,
} => Self::Movie {
parent,
id,
media_file_name,
poster_file_name,
},
collection::Scanner::Show {
parent,
id,
poster_file_name,
} => Self::Show {
parent,
id,
poster_file_name,
},
collection::Scanner::Season {
show,
season,
poster_file_name,
} => Self::Season {
show,
season,
poster_file_name,
},
collection::Scanner::Episode {
show,
season,
episode,
media_file_name,
poster_file_name,
} => Self::Episode {
show,
season,
episode,
media_file_name,
poster_file_name,
},
} }
} }
} }
@@ -141,17 +55,7 @@ impl From<collection::Scanner> for Scanner {
impl From<movie::Scanner> for Scanner { impl From<movie::Scanner> for Scanner {
fn from(value: movie::Scanner) -> Self { fn from(value: movie::Scanner) -> Self {
match value { match value {
movie::Scanner::Movie { movie::Scanner::Movie(m) => Self::Movie(m),
parent,
id,
media_file_name,
poster_file_name,
} => Self::Movie {
parent,
id,
media_file_name,
poster_file_name,
},
} }
} }
} }
@@ -159,42 +63,23 @@ impl From<movie::Scanner> for Scanner {
impl From<show::Scanner> for Scanner { impl From<show::Scanner> for Scanner {
fn from(value: show::Scanner) -> Self { fn from(value: show::Scanner) -> Self {
match value { match value {
show::Scanner::Show { show::Scanner::Show(s) => Self::Show(s),
parent, show::Scanner::Season(s) => Self::Season(s),
id, show::Scanner::Episode(e) => Self::Episode(e),
poster_file_name,
} => Self::Show {
parent,
id,
poster_file_name,
},
show::Scanner::Season {
show,
season,
poster_file_name,
} => Self::Season {
show,
season,
poster_file_name,
},
show::Scanner::Episode {
show,
season,
episode,
media_file_name,
poster_file_name,
} => Self::Episode {
show,
season,
episode,
media_file_name,
poster_file_name,
},
} }
} }
} }
impl 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)
}
/// Detect the type of a folder and call the correct scanner. Use /// Detect the type of a folder and call the correct scanner. Use
/// this only for detecting possibly ambiguous media: /// this only for detecting possibly ambiguous media:
/// - Collections /// - Collections
@@ -202,7 +87,7 @@ impl Scanner {
/// - Shows /// - Shows
pub fn scan_detect_folder( pub fn scan_detect_folder(
path: &Path, path: &Path,
parent: Option<CollectionId>, parent: Option<MediaRef<CollectionId>>,
) -> impl Stream<Item = Item> { ) -> impl Stream<Item = Item> {
enum MediaType { enum MediaType {
Collection, Collection,
@@ -211,7 +96,7 @@ impl Scanner {
} }
let media_folder_re = MEDIA_FOLDER_REGEX.get_or_init(|| { let media_folder_re = MEDIA_FOLDER_REGEX.get_or_init(|| {
Regex::new(r"^[[[:alnum:]] -]+ \([[:digit:]]+\) \[[[:digit:]]+\]$") Regex::new(r"^[[[:alnum:]]' -]+ \([[:digit:]]+\)( \[[[:digit:]]+\])?$")
.unwrap_or_else(|err| panic!("regex is invalid: {err}")) .unwrap_or_else(|err| panic!("regex is invalid: {err}"))
}); });
let season_folder_re = SEASON_FOLDER_REGEX.get_or_init(|| { let season_folder_re = SEASON_FOLDER_REGEX.get_or_init(|| {
@@ -227,16 +112,23 @@ impl Scanner {
return; return;
}; };
let Some(Ok(id)) = dir_name let dir_name = Self::strip_numeric_prefix(dir_name);
// Use the explicit ID ("[X]") if it exists, otherwise parse the folder name
let media_id = if let Some((id_str, _)) = dir_name
.split_once('[') .split_once('[')
.and_then(|(_, s)| s.split_once(']')) .and_then(|(_, s)| s.split_once(']'))
.map(|(s, _)| s.parse::<RawId>()) {
else { let Ok(id) = id_str.parse::<RawId>() else {
yield Item { yield Item {
path: path.to_owned(), path: path.to_owned(),
event: Err(Error::UnexpectedFolder), event: Err(Error::UnexpectedFolder),
};
return;
}; };
return; Either::Left(id)
} else {
Either::Right(flix_model::text::normalize_fs_name(dir_name))
}; };
let media_type: MediaType; let media_type: MediaType;
@@ -257,11 +149,13 @@ impl Scanner {
for await dir in ReadDirStream::new(dirs) { for await dir in ReadDirStream::new(dirs) {
match dir { match dir {
Ok(dir) => { Ok(dir) => {
let path = dir.path();
let filetype = match dir.file_type().await { let filetype = match dir.file_type().await {
Ok(filetype) => filetype, Ok(filetype) => filetype,
Err(err) => { Err(err) => {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::FileType(err)), event: Err(Error::FileType(err)),
}; };
continue; continue;
@@ -271,11 +165,9 @@ impl Scanner {
continue; continue;
} }
let dir_path = dir.path(); let Some(folder_name) = path.file_name().and_then(OsStr::to_str) else {
let Some(folder_name) = dir_path.file_name().and_then(OsStr::to_str)
else {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::UnexpectedFolder), event: Err(Error::UnexpectedFolder),
}; };
continue; continue;
@@ -306,24 +198,32 @@ impl Scanner {
match media_type { match media_type {
MediaType::Collection => { MediaType::Collection => {
for await event in collection::Scanner::scan_collection( let id = match media_id {
path, Either::Left(raw) => MediaRef::Id(CollectionId::from_raw(raw)),
parent, Either::Right(slug) => MediaRef::Slug(slug),
CollectionId::from_raw(id), };
) {
for await event in collection::Scanner::scan_collection(path, parent, id) {
yield event.map(|e| e.into()); yield event.map(|e| e.into());
} }
} }
MediaType::Movie => { MediaType::Movie => {
for await event in let id = match media_id {
movie::Scanner::scan_movie(path, parent, MovieId::from_raw(id)) Either::Left(raw) => MediaRef::Id(MovieId::from_raw(raw)),
{ Either::Right(slug) => MediaRef::Slug(slug),
};
for await event in movie::Scanner::scan_movie(path, parent, id) {
yield event.map(|e| e.into()); yield event.map(|e| e.into());
} }
} }
MediaType::Show => { MediaType::Show => {
for await event in show::Scanner::scan_show(path, parent, ShowId::from_raw(id)) let id = match media_id {
{ Either::Left(raw) => MediaRef::Id(ShowId::from_raw(raw)),
Either::Right(slug) => MediaRef::Slug(slug),
};
for await event in show::Scanner::scan_show(path, parent, id) {
yield event.map(|e| e.into()); yield event.map(|e| e.into());
} }
} }
+83
View File
@@ -3,6 +3,9 @@
//! 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. //! automatically detect and use the appropriate scanner.
use flix_model::id::{CollectionId, MovieId, ShowId};
use flix_model::numbers::{EpisodeNumbers, SeasonNumber};
pub mod library; pub mod library;
pub mod generic; pub mod generic;
@@ -14,3 +17,83 @@ pub mod movie;
pub mod episode; pub mod episode;
pub mod season; pub mod season;
pub mod show; pub mod show;
/// A reference to a piece of media
#[derive(Debug, Clone)]
pub enum MediaRef<ID> {
/// An explicit ID
Id(ID),
/// A filesystem slug
Slug(String),
}
impl<ID> MediaRef<ID> {
/// Get the slug if it exists
pub fn into_slug(self) -> Option<String> {
match self {
MediaRef::Id(_) => None,
MediaRef::Slug(slug) => Some(slug),
}
}
}
/// A scanned collection
#[derive(Debug)]
pub struct CollectionScan {
/// The ID of the parent collection (if any)
pub parent_ref: Option<MediaRef<CollectionId>>,
/// The ID of the collection
pub id_ref: MediaRef<CollectionId>,
/// The file name of the poster file
pub poster_file_name: Option<String>,
}
/// A scanned movie
#[derive(Debug)]
pub struct MovieScan {
/// The ID of the parent collection (if any)
pub parent_ref: Option<MediaRef<CollectionId>>,
/// The ID of the movie
pub id_ref: MediaRef<MovieId>,
/// The file name of the media file
pub media_file_name: String,
/// The file name of the poster file
pub poster_file_name: Option<String>,
}
/// A scanned show
#[derive(Debug)]
pub struct ShowScan {
/// The ID of the parent collection (if any)
pub parent_ref: Option<MediaRef<CollectionId>>,
/// The ID of the show
pub id_ref: MediaRef<ShowId>,
/// The file name of the poster file
pub poster_file_name: Option<String>,
}
/// A scanned season
#[derive(Debug)]
pub struct SeasonScan {
/// The ID of the show this season belongs to
pub show_ref: MediaRef<ShowId>,
/// The season this episode belongs to
pub season: SeasonNumber,
/// The file name of the poster file
pub poster_file_name: Option<String>,
}
/// A scanned episode
#[derive(Debug)]
pub struct EpisodeScan {
/// The ID of the show this episode belongs to
pub show_ref: MediaRef<ShowId>,
/// The season this episode belongs to
pub season: SeasonNumber,
/// The number(s) of this episode
pub episode: EpisodeNumbers,
/// The file name of the media file
pub media_file_name: String,
/// The file name of the poster file
pub poster_file_name: Option<String>,
}
+15 -22
View File
@@ -12,6 +12,7 @@ use tokio_stream::wrappers::ReadDirStream;
use crate::Error; use crate::Error;
use crate::macros::{is_image_extension, is_media_extension}; 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>; pub type Item = crate::Item<Scanner>;
@@ -19,24 +20,15 @@ pub type Item = crate::Item<Scanner>;
/// The scanner for movies /// The scanner for movies
pub enum Scanner { pub enum Scanner {
/// A scanned movie /// A scanned movie
Movie { Movie(MovieScan),
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the movie
id: MovieId,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
} }
impl Scanner { impl Scanner {
/// Scan a folder for a movie /// Scan a folder for a movie
pub fn scan_movie( pub fn scan_movie(
path: &Path, path: &Path,
parent: Option<CollectionId>, parent_ref: Option<MediaRef<CollectionId>>,
id: MovieId, id_ref: MediaRef<MovieId>,
) -> impl Stream<Item = Item> { ) -> impl Stream<Item = Item> {
stream!({ stream!({
let dirs = match fs::read_dir(path).await { let dirs = match fs::read_dir(path).await {
@@ -56,11 +48,13 @@ impl Scanner {
for await dir in ReadDirStream::new(dirs) { for await dir in ReadDirStream::new(dirs) {
match dir { match dir {
Ok(dir) => { Ok(dir) => {
let path = dir.path();
let filetype = match dir.file_type().await { let filetype = match dir.file_type().await {
Ok(filetype) => filetype, Ok(filetype) => filetype,
Err(err) => { Err(err) => {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::FileType(err)), event: Err(Error::FileType(err)),
}; };
continue; continue;
@@ -68,18 +62,17 @@ impl Scanner {
}; };
if !filetype.is_file() { if !filetype.is_file() {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::UnexpectedNonFile), event: Err(Error::UnexpectedNonFile),
}; };
continue; continue;
} }
let path = dir.path();
match path.extension().and_then(OsStr::to_str) { match path.extension().and_then(OsStr::to_str) {
is_media_extension!() => { is_media_extension!() => {
if media_file_name.is_some() { if media_file_name.is_some() {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::DuplicateMediaFile), event: Err(Error::DuplicateMediaFile),
}; };
continue; continue;
@@ -93,7 +86,7 @@ impl Scanner {
is_image_extension!() => { is_image_extension!() => {
if poster_file_name.is_some() { if poster_file_name.is_some() {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::DuplicatePosterFile), event: Err(Error::DuplicatePosterFile),
}; };
continue; continue;
@@ -105,7 +98,7 @@ impl Scanner {
} }
Some(_) | None => { Some(_) | None => {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::UnexpectedFile), event: Err(Error::UnexpectedFile),
}; };
} }
@@ -130,12 +123,12 @@ impl Scanner {
yield Item { yield Item {
path: path.to_owned(), path: path.to_owned(),
event: Ok(Self::Movie { event: Ok(Self::Movie(MovieScan {
parent, parent_ref,
id, id_ref,
media_file_name, media_file_name,
poster_file_name, poster_file_name,
}), })),
}; };
}) })
} }
+22 -51
View File
@@ -13,53 +13,23 @@ use tokio_stream::wrappers::ReadDirStream;
use crate::Error; use crate::Error;
use crate::macros::is_image_extension; use crate::macros::is_image_extension;
use crate::scanner::episode; use crate::scanner::{EpisodeScan, MediaRef, SeasonScan, episode};
/// A season item /// A season item
pub type Item = crate::Item<Scanner>; pub type Item = crate::Item<Scanner>;
/// The scanner for seasons /// The scanner for seasons
pub enum Scanner { pub enum Scanner {
/// A scanned season
Season(SeasonScan),
/// A scanned episode /// A scanned episode
Season { Episode(EpisodeScan),
/// The ID of the show this season belongs to
show: ShowId,
/// The season this episode belongs to
season: SeasonNumber,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned episode
Episode {
/// The ID of the show this episode belongs to
show: ShowId,
/// The season this episode belongs to
season: SeasonNumber,
/// The number(s) of this episode
episode: EpisodeNumbers,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
} }
impl From<episode::Scanner> for Scanner { impl From<episode::Scanner> for Scanner {
fn from(value: episode::Scanner) -> Self { fn from(value: episode::Scanner) -> Self {
match value { match value {
episode::Scanner::Episode { episode::Scanner::Episode(e) => Self::Episode(e),
show,
season,
episode,
media_file_name,
poster_file_name,
} => Self::Episode {
show,
season,
episode,
media_file_name,
poster_file_name,
},
} }
} }
} }
@@ -68,7 +38,7 @@ impl Scanner {
/// Scan a folder for a season and its episodes /// Scan a folder for a season and its episodes
pub fn scan_season( pub fn scan_season(
path: &Path, path: &Path,
show: ShowId, show_ref: MediaRef<ShowId>,
season: SeasonNumber, season: SeasonNumber,
) -> impl Stream<Item = Item> { ) -> impl Stream<Item = Item> {
stream!({ stream!({
@@ -89,18 +59,19 @@ impl Scanner {
for await dir in ReadDirStream::new(dirs) { for await dir in ReadDirStream::new(dirs) {
match dir { match dir {
Ok(dir) => { Ok(dir) => {
let path = dir.path();
let filetype = match dir.file_type().await { let filetype = match dir.file_type().await {
Ok(filetype) => filetype, Ok(filetype) => filetype,
Err(err) => { Err(err) => {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::FileType(err)), event: Err(Error::FileType(err)),
}; };
continue; continue;
} }
}; };
let path = dir.path();
if filetype.is_dir() { if filetype.is_dir() {
episode_dirs_to_scan.push(path); episode_dirs_to_scan.push(path);
continue; continue;
@@ -110,7 +81,7 @@ impl Scanner {
is_image_extension!() => { is_image_extension!() => {
if poster_file_name.is_some() { if poster_file_name.is_some() {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::DuplicatePosterFile), event: Err(Error::DuplicatePosterFile),
}; };
continue; continue;
@@ -122,7 +93,7 @@ impl Scanner {
} }
Some(_) | None => { Some(_) | None => {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::UnexpectedFile), event: Err(Error::UnexpectedFile),
}; };
} }
@@ -139,17 +110,17 @@ impl Scanner {
yield Item { yield Item {
path: path.to_owned(), path: path.to_owned(),
event: Ok(Self::Season { event: Ok(Self::Season(SeasonScan {
show, show_ref: show_ref.clone(),
season, season,
poster_file_name, poster_file_name,
}), })),
}; };
for episode_dir in episode_dirs_to_scan { for episode_dir in episode_dirs_to_scan {
let Some(episode_dir_name) = episode_dir.file_name().and_then(OsStr::to_str) else { let Some(episode_dir_name) = episode_dir.file_name().and_then(OsStr::to_str) else {
yield Item { yield Item {
path: path.to_owned(), path: episode_dir,
event: Err(Error::UnexpectedFolder), event: Err(Error::UnexpectedFolder),
}; };
continue; continue;
@@ -157,14 +128,14 @@ impl Scanner {
let Some((_, s_e_str)) = episode_dir_name.split_once('S') else { let Some((_, s_e_str)) = episode_dir_name.split_once('S') else {
yield Item { yield Item {
path: path.to_owned(), path: episode_dir,
event: Err(Error::UnexpectedFolder), event: Err(Error::UnexpectedFolder),
}; };
continue; continue;
}; };
let Some((s_str, e_str)) = s_e_str.split_once('E') else { let Some((s_str, e_str)) = s_e_str.split_once('E') else {
yield Item { yield Item {
path: path.to_owned(), path: episode_dir,
event: Err(Error::UnexpectedFolder), event: Err(Error::UnexpectedFolder),
}; };
continue; continue;
@@ -172,14 +143,14 @@ impl Scanner {
let Ok(season_number) = s_str.parse::<SeasonNumber>() else { let Ok(season_number) = s_str.parse::<SeasonNumber>() else {
yield Item { yield Item {
path: path.to_owned(), path: episode_dir,
event: Err(Error::UnexpectedFolder), event: Err(Error::UnexpectedFolder),
}; };
continue; continue;
}; };
if season_number != season { if season_number != season {
yield Item { yield Item {
path: path.to_owned(), path: episode_dir,
event: Err(Error::Inconsistent), event: Err(Error::Inconsistent),
}; };
continue; continue;
@@ -191,14 +162,14 @@ impl Scanner {
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
else { else {
yield Item { yield Item {
path: path.to_owned(), path: episode_dir,
event: Err(Error::UnexpectedFolder), event: Err(Error::UnexpectedFolder),
}; };
continue; continue;
}; };
let Ok(episode_numbers) = EpisodeNumbers::try_from(episode_numbers.as_ref()) else { let Ok(episode_numbers) = EpisodeNumbers::try_from(episode_numbers.as_ref()) else {
yield Item { yield Item {
path: path.to_owned(), path: episode_dir,
event: Err(Error::UnexpectedFolder), event: Err(Error::UnexpectedFolder),
}; };
continue; continue;
@@ -206,7 +177,7 @@ impl Scanner {
for await event in episode::Scanner::scan_episode( for await event in episode::Scanner::scan_episode(
&episode_dir, &episode_dir,
show, show_ref.clone(),
season_number, season_number,
episode_numbers, episode_numbers,
) { ) {
+24 -66
View File
@@ -4,7 +4,7 @@ use std::ffi::OsStr;
use std::path::Path; use std::path::Path;
use flix_model::id::{CollectionId, ShowId}; use flix_model::id::{CollectionId, ShowId};
use flix_model::numbers::{EpisodeNumbers, SeasonNumber}; use flix_model::numbers::SeasonNumber;
use async_stream::stream; use async_stream::stream;
use tokio::fs; use tokio::fs;
@@ -13,7 +13,7 @@ use tokio_stream::wrappers::ReadDirStream;
use crate::Error; use crate::Error;
use crate::macros::is_image_extension; use crate::macros::is_image_extension;
use crate::scanner::season; use crate::scanner::{EpisodeScan, MediaRef, SeasonScan, ShowScan, season};
/// A show item /// A show item
pub type Item = crate::Item<Scanner>; pub type Item = crate::Item<Scanner>;
@@ -21,63 +21,18 @@ pub type Item = crate::Item<Scanner>;
/// The scanner for shows /// The scanner for shows
pub enum Scanner { pub enum Scanner {
/// A scanned show /// A scanned show
Show { Show(ShowScan),
/// The ID of the parent collection (if any) /// A scanned season
parent: Option<CollectionId>, Season(SeasonScan),
/// The ID of the show
id: ShowId,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned episode /// A scanned episode
Season { Episode(EpisodeScan),
/// The ID of the show this season belongs to
show: ShowId,
/// The season this episode belongs to
season: SeasonNumber,
/// The file name of the poster file
poster_file_name: Option<String>,
},
/// A scanned episode
Episode {
/// The ID of the show this episode belongs to
show: ShowId,
/// The season this episode belongs to
season: SeasonNumber,
/// The number(s) of this episode
episode: EpisodeNumbers,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
} }
impl From<season::Scanner> for Scanner { impl From<season::Scanner> for Scanner {
fn from(value: season::Scanner) -> Self { fn from(value: season::Scanner) -> Self {
match value { match value {
season::Scanner::Season { season::Scanner::Season(s) => Self::Season(s),
show, season::Scanner::Episode(e) => Self::Episode(e),
season,
poster_file_name,
} => Self::Season {
show,
season,
poster_file_name,
},
season::Scanner::Episode {
show,
season,
episode,
media_file_name,
poster_file_name,
} => Self::Episode {
show,
season,
episode,
media_file_name,
poster_file_name,
},
} }
} }
} }
@@ -86,8 +41,8 @@ impl Scanner {
/// Scan a folder for a show and its seasons/episodes /// Scan a folder for a show and its seasons/episodes
pub fn scan_show( pub fn scan_show(
path: &Path, path: &Path,
parent: Option<CollectionId>, parent_ref: Option<MediaRef<CollectionId>>,
id: ShowId, id_ref: MediaRef<ShowId>,
) -> impl Stream<Item = Item> { ) -> impl Stream<Item = Item> {
stream!({ stream!({
let dirs = match fs::read_dir(path).await { let dirs = match fs::read_dir(path).await {
@@ -107,18 +62,19 @@ impl Scanner {
for await dir in ReadDirStream::new(dirs) { for await dir in ReadDirStream::new(dirs) {
match dir { match dir {
Ok(dir) => { Ok(dir) => {
let path = dir.path();
let filetype = match dir.file_type().await { let filetype = match dir.file_type().await {
Ok(filetype) => filetype, Ok(filetype) => filetype,
Err(err) => { Err(err) => {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::FileType(err)), event: Err(Error::FileType(err)),
}; };
continue; continue;
} }
}; };
let path = dir.path();
if filetype.is_dir() { if filetype.is_dir() {
season_dirs_to_scan.push(path); season_dirs_to_scan.push(path);
continue; continue;
@@ -128,7 +84,7 @@ impl Scanner {
is_image_extension!() => { is_image_extension!() => {
if poster_file_name.is_some() { if poster_file_name.is_some() {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::DuplicatePosterFile), event: Err(Error::DuplicatePosterFile),
}; };
continue; continue;
@@ -140,7 +96,7 @@ impl Scanner {
} }
Some(_) | None => { Some(_) | None => {
yield Item { yield Item {
path: path.to_owned(), path,
event: Err(Error::UnexpectedFile), event: Err(Error::UnexpectedFile),
}; };
} }
@@ -157,17 +113,17 @@ impl Scanner {
yield Item { yield Item {
path: path.to_owned(), path: path.to_owned(),
event: Ok(Self::Show { event: Ok(Self::Show(ShowScan {
parent, parent_ref,
id, id_ref: id_ref.clone(),
poster_file_name, poster_file_name,
}), })),
}; };
for season_dir in season_dirs_to_scan { for season_dir in season_dirs_to_scan {
let Some(season_dir_name) = season_dir.file_name().and_then(OsStr::to_str) else { let Some(season_dir_name) = season_dir.file_name().and_then(OsStr::to_str) else {
yield Item { yield Item {
path: path.to_owned(), path: season_dir,
event: Err(Error::UnexpectedFolder), event: Err(Error::UnexpectedFolder),
}; };
continue; continue;
@@ -178,13 +134,15 @@ impl Scanner {
.map(|(_, s)| s.parse::<SeasonNumber>()) .map(|(_, s)| s.parse::<SeasonNumber>())
else { else {
yield Item { yield Item {
path: path.to_owned(), path: season_dir,
event: Err(Error::UnexpectedFolder), event: Err(Error::UnexpectedFolder),
}; };
continue; continue;
}; };
for await event in season::Scanner::scan_season(&season_dir, id, season_number) { for await event in
season::Scanner::scan_season(&season_dir, id_ref.clone(), season_number)
{
yield event.map(|e| e.into()); yield event.map(|e| e.into());
} }
} }
+11 -11
View File
@@ -1,29 +1,29 @@
[package] [package]
name = "flix-model" name = "flix-model"
version = "0.0.13" version = "0.0.19"
license-file.workspace = true
categories = []
description = "Core types for flix data" description = "Core types for flix data"
repository = "https://github.com/QuantumShade/flix" repository = "https://github.com/QuantumShade/flix"
categories = []
authors.workspace = true
edition.workspace = true edition.workspace = true
license-file.workspace = true
rust-version.workspace = true rust-version.workspace = true
[package.metadata.docs.rs] [package.metadata.docs.rs]
all-features = true all-features = true
rustdoc-args = ["--cfg", "docsrs"] rustdoc-args = ["--cfg", "docsrs"]
[lints] [dependencies]
workspace = true itertools = { workspace = true }
seamantic = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true, features = ["derive", "std"], optional = true }
[features] [features]
default = [] default = []
serde = ["dep:serde"] serde = ["dep:serde"]
[dependencies] [lints]
seamantic = { workspace = true } workspace = true
serde = { workspace = true, optional = true, features = ["std", "derive"] }
thiserror = { workspace = true }
+1
View File
@@ -4,3 +4,4 @@
pub mod id; pub mod id;
pub mod numbers; pub mod numbers;
pub mod text;
+89 -6
View File
@@ -1,12 +1,76 @@
//! 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; use core::ops::RangeInclusive;
use core::str::FromStr;
use std::collections::HashSet; use std::collections::HashSet;
/// Type alias for representing season numbers use seamantic::sea_orm;
pub type SeasonNumber = u32;
/// Type alias for representing episode numbers /// Newtype for representing season numbers
pub type EpisodeNumber = u32; #[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, sea_orm::DeriveValueType,
)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
#[repr(transparent)]
pub struct SeasonNumber(u32);
impl SeasonNumber {
/// Create a `SeasonNumber` from an integer
pub fn new(value: u32) -> Self {
Self(value)
}
}
impl fmt::Display for SeasonNumber {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl FromStr for SeasonNumber {
type Err = <u32 as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
u32::from_str(s).map(Self)
}
}
/// Newtype for representing episode numbers
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, sea_orm::DeriveValueType,
)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
#[repr(transparent)]
pub struct EpisodeNumber(u32);
impl EpisodeNumber {
/// Create an `EpisodeNumber` from an integer
pub fn new(value: u32) -> Self {
Self(value)
}
/// Get the underlying value
pub fn into_inner(self) -> u32 {
self.0
}
}
impl fmt::Display for EpisodeNumber {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl FromStr for EpisodeNumber {
type Err = <u32 as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
u32::from_str(s).map(Self)
}
}
/// Potential errors when building EpisodeNumbers /// Potential errors when building EpisodeNumbers
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -20,7 +84,7 @@ pub enum Error {
} }
/// A wrapper for handling single and multi-episode entries /// A wrapper for handling single and multi-episode entries
#[derive(Debug)] #[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct EpisodeNumbers(RangeInclusive<EpisodeNumber>); pub struct EpisodeNumbers(RangeInclusive<EpisodeNumber>);
@@ -37,7 +101,7 @@ impl TryFrom<&[EpisodeNumber]> for EpisodeNumbers {
let max = value.iter().copied().max().unwrap_or_default(); let max = value.iter().copied().max().unwrap_or_default();
let len = value.len(); let len = value.len();
if usize::try_from(max.saturating_sub(min).saturating_add(1)) != Ok(len) { if usize::try_from(max.0.saturating_sub(min.0).saturating_add(1)) != Ok(len) {
return Err(Error::Noncontiguous); return Err(Error::Noncontiguous);
} }
@@ -53,8 +117,27 @@ impl TryFrom<&[EpisodeNumber]> for EpisodeNumbers {
} }
impl EpisodeNumbers { impl EpisodeNumbers {
/// Create an [EpisodeNumbers] from a starting number and a count.
/// `count` should be zero for single episodes.
pub fn new(start: EpisodeNumber, count: u8) -> Self {
Self(start..=EpisodeNumber(start.0.saturating_add(count.into())))
}
/// Get the range of episodes /// Get the range of episodes
pub fn as_range(&self) -> &RangeInclusive<EpisodeNumber> { pub fn as_range(&self) -> &RangeInclusive<EpisodeNumber> {
&self.0 &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`
pub fn range_string(&self) -> String {
let start = self.0.start();
let end = self.0.end();
if start == end {
format!("{:02}", start)
} else {
format!("{:02}-{:02}", start, end)
}
}
} }
+168
View File
@@ -0,0 +1,168 @@
//! This module contains helper functions for normalizing media titles
use core::iter::Peekable;
use itertools::Itertools;
/// # 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}");
}
input
.split_ascii_whitespace()
.map(|s| {
let chars = s
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.map(|c| c.to_ascii_lowercase());
if s.len() > 4
&& s.len().is_multiple_of(2)
&& chars.clone().tuples().all(|(l, r)| l != '.' && r == '.')
{
// Collapse acronym
chars.tuples().map(|(l, _)| l).collect()
} else {
chars.collect()
}
})
.filter(|part: &String| !part.is_empty())
}
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) {
Some("a" | "an" | "the") => (iter.next(), iter),
_ => (None, iter),
}
}
/// Convert a media title to be sortable and searchable
///
/// use flix_model::text::make_sortable_title;
///
/// assert_eq!(make_sortable_title("The Matrix"), "matrix, the");
/// assert_eq!(make_sortable_title("Marvel's Agents of S.H.I.E.L.D."), "marvels agents of shield");
/// assert_eq!(make_sortable_title("Avatar: The Last Airbender"), "avatar the last airbender");
///
/// # Panics
///
/// Panics if `input` is not ASCII.
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());
if let Some(article) = article {
output.chain([", ".to_string(), article]).collect()
} else {
output.collect()
}
}
/// Convert a media title to a folder name representable on filesystems
///
/// use flix_model::text::make_fs_slug;
///
/// assert_eq!(make_fs_slug("The Matrix"), "matrix");
/// assert_eq!(make_fs_slug("Marvel's Agents of S.H.I.E.L.D."), "marvels agents of shield");
/// assert_eq!(make_fs_slug("Avatar: The Last Airbender"), "avatar the last airbender");
///
/// # Panics
///
/// Panics if `input` is not ASCII.
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()
}
/// Convert a media title and year to a folder name representable on filesystems
///
/// use flix_model::text::make_fs_slug_year;
///
/// assert_eq!(make_fs_slug_year("The Matrix", 1999), "matrix (1999)");
/// assert_eq!(make_fs_slug_year("Marvel's Agents of S.H.I.E.L.D.", 2013), "marvels agents of shield (2013)");
/// assert_eq!(make_fs_slug_year("Avatar: The Last Airbender", 2005), "avatar the last airbender (2005)");
///
/// # Panics
///
/// Panics if `input` is not ASCII.
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())
.chain([format!(" ({year})")])
.collect()
}
/// Normalize a filesystem name
///
/// use flix_model::text::normalize_fs_name;
///
/// assert_eq!(normalize_fs_name("Matrix (1999)"), "matrix (1999)");
/// 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)");
pub fn normalize_fs_name(input: &str) -> String {
let chars = input.split_ascii_whitespace().map(|s| {
let chars = s
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '(' || *c == ')')
.map(|c| c.to_ascii_lowercase());
if s.len() > 4
&& s.len().is_multiple_of(2)
&& chars.clone().tuples().all(|(l, r)| l != '.' && r == '.')
{
// Collapse acronym
chars.tuples().map(|(l, _)| l).collect()
} else {
chars.collect()
}
});
Itertools::intersperse(chars, " ".to_string()).collect()
}
/// Convert a media title to a url compatible string
///
/// use flix_model::text::make_web_slug;
///
/// assert_eq!(make_web_slug("The Matrix"), "matrix");
/// assert_eq!(make_web_slug("Marvel's Agents of S.H.I.E.L.D."), "marvels-agents-of-shield");
/// assert_eq!(make_web_slug("Avatar: The Last Airbender"), "avatar-the-last-airbender");
///
/// # Panics
///
/// Panics if `input` is not ASCII.
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()
}
/// Convert a media title and year to a url compatible string
///
/// use flix_model::text::make_web_slug_year;
///
/// assert_eq!(make_web_slug_year("The Matrix", 1999), "matrix-1999");
/// assert_eq!(make_web_slug_year("Marvel's Agents of S.H.I.E.L.D.", 2013), "marvels-agents-of-shield-2013");
/// assert_eq!(make_web_slug_year("Avatar: The Last Airbender", 2005), "avatar-the-last-airbender-2005");
///
/// # Panics
///
/// Panics if `input` is not ASCII.
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())
.chain([format!("-{year}")])
.collect()
}
+16 -15
View File
@@ -1,35 +1,29 @@
[package] [package]
name = "flix-tmdb" name = "flix-tmdb"
version = "0.0.13" version = "0.0.19"
license-file.workspace = true
categories = []
description = "Clients and models for fetching data from TMDB" description = "Clients and models for fetching data from TMDB"
repository = "https://github.com/QuantumShade/flix" repository = "https://github.com/QuantumShade/flix"
categories = []
authors.workspace = true
edition.workspace = true edition.workspace = true
license-file.workspace = true
rust-version.workspace = true rust-version.workspace = true
[package.metadata.docs.rs] [package.metadata.docs.rs]
all-features = true all-features = true
rustdoc-args = ["--cfg", "docsrs"] rustdoc-args = ["--cfg", "docsrs"]
[lints]
workspace = true
[features]
default = []
sea-orm = ["dep:sea-orm"]
[dependencies] [dependencies]
flix-model = { workspace = true } bytes = { workspace = true }
chrono = { workspace = true, features = ["serde"] } chrono = { workspace = true, features = ["serde"] }
governor = { workspace = true, features = ["std", "jitter"] } flix-model = { workspace = true, features = ["serde"] }
governor = { workspace = true, features = ["jitter", "std"] }
nonzero_ext = { workspace = true } nonzero_ext = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls-tls"] } redb = { workspace = true }
reqwest = { workspace = true, features = ["query", "rustls"] }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
url = { workspace = true } url = { workspace = true }
url-macro = { workspace = true } url-macro = { workspace = true }
@@ -38,3 +32,10 @@ sea-orm = { workspace = true, optional = true }
[dev-dependencies] [dev-dependencies]
serde_test = { workspace = true } serde_test = { workspace = true }
[features]
default = []
sea-orm = ["dep:sea-orm"]
[lints]
workspace = true
+17 -26
View File
@@ -1,25 +1,30 @@
//! Collections API //! Collections API
use core::time::Duration;
use std::rc::Rc; use std::rc::Rc;
use std::sync::RwLock;
use governor::Jitter; use crate::api::exec_request;
use crate::Config;
use crate::model::Collection; use crate::model::Collection;
use crate::model::id::CollectionId; use crate::model::id::CollectionId;
use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request}; use super::{Error, make_request};
/// TMDB Collections API client /// TMDB Collections API client
pub struct Client { pub struct Client {
config: Rc<Config>, config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
} }
impl Client { impl Client {
/// Create a new client with the given configuration /// Create a new client with the given configuration
pub fn new(config: Rc<Config>) -> Self { pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
Self { config } Self {
config,
cache,
policy,
}
} }
} }
@@ -30,25 +35,11 @@ impl Client {
id: impl Into<CollectionId>, id: impl Into<CollectionId>,
language: Option<&str>, language: Option<&str>,
) -> Result<Collection, Error> { ) -> Result<Collection, Error> {
self.config let request = make_request(
.limiter &self.config,
.until_ready_with_jitter(Jitter::new( &format!("/3/collection/{}", id.into().into_raw()),
Duration::from_millis(0), language,
Duration::from_millis(50), )?;
)) exec_request(&self.config, &*self.cache, &self.policy, request).await
.await;
Ok(self
.config
.client
.execute(make_request(
&self.config,
&format!("/3/collection/{}", id.into().into_raw()),
language,
)?)
.await?
.error_for_status()?
.json()
.await?)
} }
} }
+22 -31
View File
@@ -1,27 +1,32 @@
//! Episodes API //! Episodes API
use core::time::Duration;
use std::rc::Rc; use std::rc::Rc;
use std::sync::RwLock;
use flix_model::numbers::{EpisodeNumber, SeasonNumber}; use flix_model::numbers::{EpisodeNumber, SeasonNumber};
use governor::Jitter; use crate::api::exec_request;
use crate::Config;
use crate::model::Episode; use crate::model::Episode;
use crate::model::id::ShowId; use crate::model::id::ShowId;
use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request}; use super::{Error, make_request};
/// TMDB Episodes API client /// TMDB Episodes API client
pub struct Client { pub struct Client {
config: Rc<Config>, config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
} }
impl Client { impl Client {
/// Create a new client with the given configuration /// Create a new client with the given configuration
pub fn new(config: Rc<Config>) -> Self { pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
Self { config } Self {
config,
cache,
policy,
}
} }
} }
@@ -34,30 +39,16 @@ impl Client {
episode: impl Into<EpisodeNumber>, episode: impl Into<EpisodeNumber>,
language: Option<&str>, language: Option<&str>,
) -> Result<Episode, Error> { ) -> Result<Episode, Error> {
self.config let request = make_request(
.limiter &self.config,
.until_ready_with_jitter(Jitter::new( &format!(
Duration::from_millis(0), "/3/tv/{}/season/{}/episode/{}",
Duration::from_millis(50), id.into().into_raw(),
)) season.into(),
.await; episode.into()
),
Ok(self language,
.config )?;
.client exec_request(&self.config, &*self.cache, &self.policy, request).await
.execute(make_request(
&self.config,
&format!(
"/3/tv/{}/season/{}/episode/{}",
id.into().into_raw(),
season.into(),
episode.into()
),
language,
)?)
.await?
.error_for_status()?
.json()
.await?)
} }
} }
+65 -1
View File
@@ -1,9 +1,15 @@
//! TMDB API clients //! TMDB API clients
use core::ops::Deref;
use core::time::Duration;
use std::sync::RwLock;
use governor::Jitter;
use reqwest::Request; use reqwest::Request;
use reqwest::header; use reqwest::header;
use serde::de::DeserializeOwned;
use crate::Config; use crate::{Cache, CachePolicy, Config};
pub mod collections; pub mod collections;
pub mod episodes; pub mod episodes;
@@ -20,6 +26,9 @@ pub enum Error {
/// Reqwest error wrapper /// Reqwest error wrapper
#[error("reqwest error: {0}")] #[error("reqwest error: {0}")]
Reqwest(#[from] reqwest::Error), Reqwest(#[from] reqwest::Error),
/// Json error wrapper
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
} }
fn make_request(config: &Config, path: &str, language: Option<&str>) -> Result<Request, Error> { fn make_request(config: &Config, path: &str, language: Option<&str>) -> Result<Request, Error> {
@@ -38,3 +47,58 @@ fn make_request(config: &Config, path: &str, language: Option<&str>) -> Result<R
Ok(builder.build()?) Ok(builder.build()?)
} }
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 path = request.url().path().to_owned();
// read the cache and fall back to reqwest
let mut response = None;
if let Some(cache) = read_cache {
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?
}
};
// write to the cache if needed
if let Some(cache) = write_cache
&& needs_cache_write
{
cache.set(&path, &response);
}
Ok(serde_json::from_slice(&response)?)
}
+17 -26
View File
@@ -1,25 +1,30 @@
//! Movies API //! Movies API
use core::time::Duration;
use std::rc::Rc; use std::rc::Rc;
use std::sync::RwLock;
use governor::Jitter; use crate::api::exec_request;
use crate::Config;
use crate::model::Movie; use crate::model::Movie;
use crate::model::id::MovieId; use crate::model::id::MovieId;
use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request}; use super::{Error, make_request};
/// TMDB Movies API client /// TMDB Movies API client
pub struct Client { pub struct Client {
config: Rc<Config>, config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
} }
impl Client { impl Client {
/// Create a new client with the given configuration /// Create a new client with the given configuration
pub fn new(config: Rc<Config>) -> Self { pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
Self { config } Self {
config,
cache,
policy,
}
} }
} }
@@ -30,25 +35,11 @@ impl Client {
id: impl Into<MovieId>, id: impl Into<MovieId>,
language: Option<&str>, language: Option<&str>,
) -> Result<Movie, Error> { ) -> Result<Movie, Error> {
self.config let request = make_request(
.limiter &self.config,
.until_ready_with_jitter(Jitter::new( &format!("/3/movie/{}", id.into().into_raw()),
Duration::from_millis(0), language,
Duration::from_millis(50), )?;
)) exec_request(&self.config, &*self.cache, &self.policy, request).await
.await;
Ok(self
.config
.client
.execute(make_request(
&self.config,
&format!("/3/movie/{}", id.into().into_raw()),
language,
)?)
.await?
.error_for_status()?
.json()
.await?)
} }
} }
+17 -26
View File
@@ -1,27 +1,32 @@
//! Seasons API //! Seasons API
use core::time::Duration;
use std::rc::Rc; use std::rc::Rc;
use std::sync::RwLock;
use flix_model::numbers::SeasonNumber; use flix_model::numbers::SeasonNumber;
use governor::Jitter; use crate::api::exec_request;
use crate::Config;
use crate::model::Season; use crate::model::Season;
use crate::model::id::ShowId; use crate::model::id::ShowId;
use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request}; use super::{Error, make_request};
/// TMDB Seasons API client /// TMDB Seasons API client
pub struct Client { pub struct Client {
config: Rc<Config>, config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
} }
impl Client { impl Client {
/// Create a new client with the given configuration /// Create a new client with the given configuration
pub fn new(config: Rc<Config>) -> Self { pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
Self { config } Self {
config,
cache,
policy,
}
} }
} }
@@ -33,25 +38,11 @@ impl Client {
season: impl Into<SeasonNumber>, season: impl Into<SeasonNumber>,
language: Option<&str>, language: Option<&str>,
) -> Result<Season, Error> { ) -> Result<Season, Error> {
self.config let request = make_request(
.limiter &self.config,
.until_ready_with_jitter(Jitter::new( &format!("/3/tv/{}/season/{}", id.into().into_raw(), season.into()),
Duration::from_millis(0), language,
Duration::from_millis(50), )?;
)) exec_request(&self.config, &*self.cache, &self.policy, request).await
.await;
Ok(self
.config
.client
.execute(make_request(
&self.config,
&format!("/3/tv/{}/season/{}", id.into().into_raw(), season.into()),
language,
)?)
.await?
.error_for_status()?
.json()
.await?)
} }
} }
+17 -26
View File
@@ -1,25 +1,30 @@
//! Shows API //! Shows API
use core::time::Duration;
use std::rc::Rc; use std::rc::Rc;
use std::sync::RwLock;
use governor::Jitter; use crate::api::exec_request;
use crate::Config;
use crate::model::Show; use crate::model::Show;
use crate::model::id::ShowId; use crate::model::id::ShowId;
use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request}; use super::{Error, make_request};
/// TMDB Shows API client /// TMDB Shows API client
pub struct Client { pub struct Client {
config: Rc<Config>, config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
} }
impl Client { impl Client {
/// Create a new client with the given configuration /// Create a new client with the given configuration
pub fn new(config: Rc<Config>) -> Self { pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
Self { config } Self {
config,
cache,
policy,
}
} }
} }
@@ -30,25 +35,11 @@ impl Client {
id: impl Into<ShowId>, id: impl Into<ShowId>,
language: Option<&str>, language: Option<&str>,
) -> Result<Show, Error> { ) -> Result<Show, Error> {
self.config let request = make_request(
.limiter &self.config,
.until_ready_with_jitter(Jitter::new( &format!("/3/tv/{}", id.into().into_raw()),
Duration::from_millis(0), language,
Duration::from_millis(50), )?;
)) exec_request(&self.config, &*self.cache, &self.policy, request).await
.await;
Ok(self
.config
.client
.execute(make_request(
&self.config,
&format!("/3/tv/{}", id.into().into_raw()),
language,
)?)
.await?
.error_for_status()?
.json()
.await?)
} }
} }
+83
View File
@@ -0,0 +1,83 @@
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use bytes::Bytes;
use redb::{Database, DatabaseError, ReadableDatabase, TableDefinition};
/// The client cache policy
pub enum CachePolicy {
/// Do not use a cache
None,
/// Use and update the cache
Full,
/// Use the cache but don't update it
Read,
/// Ignore the cache but update it
Update,
}
/// The trait representing a caching backend
pub trait Cache {
/// Get a cached value, or None
fn get(&self, query: &str) -> Option<Bytes>;
/// Set a value in the cache
fn set(&self, query: &str, response: &Bytes);
}
const TABLE: TableDefinition<&str, (u64, &[u8])> = TableDefinition::new("tmdb_responses");
/// A [Cache] implementation using [redb] as the backend
pub struct RedbCache {
db: Database,
}
impl RedbCache {
/// Create/open a [redb] database at the path
pub fn new(path: &Path) -> Result<Self, DatabaseError> {
Ok(Self {
db: Database::create(path)?,
})
}
/// 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()?;
}
write_txn.commit().ok()
}
}
impl Cache for RedbCache {
fn get(&self, query: &str) -> Option<Bytes> {
let read_txn = self.db.begin_read().ok()?;
let table = read_txn.open_table(TABLE).ok()?;
let result = table.get(query).ok()??;
let (timestamp, data) = result.value();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now.saturating_sub(timestamp) >= 60 * 60 * 24 * 30 * 6 {
None
} else {
Some(Bytes::copy_from_slice(data))
}
}
fn set(&self, query: &str, response: &Bytes) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.write(now, query, response);
}
}
+33 -13
View File
@@ -1,6 +1,7 @@
use std::rc::Rc; use std::rc::Rc;
use std::sync::RwLock;
use crate::{Config, api}; use crate::{Cache, CachePolicy, Config, api};
/// The primary client that references all other clients /// The primary client that references all other clients
pub struct Client { pub struct Client {
@@ -9,23 +10,42 @@ pub struct Client {
shows: api::shows::Client, shows: api::shows::Client,
seasons: api::seasons::Client, seasons: api::seasons::Client,
episodes: api::episodes::Client, episodes: api::episodes::Client,
cache_policy: Rc<RwLock<CachePolicy>>,
} }
impl Client { impl Client {
/// Create a new client from a default configuration using the bearer token /// Create a new client with the given configuration
pub fn new(bearer_token: String) -> Self { pub fn new(config: Config, cache: Rc<dyn Cache>, cache_policy: CachePolicy) -> Self {
Self::new_with_config(Config::new(bearer_token)) let config = Rc::new(config);
let cache_policy = Rc::new(RwLock::new(cache_policy));
Self {
collections: api::collections::Client::new(
config.clone(),
cache.clone(),
cache_policy.clone(),
),
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(),
),
cache_policy,
}
} }
/// Create a new client with the given configuration /// Modify the [CachePolicy]
pub fn new_with_config(config: Config) -> Self { pub fn set_cache_policy(&self, new_policy: CachePolicy) {
let config = Rc::new(config); match self.cache_policy.write() {
Self { Ok(mut policy) => *policy = new_policy,
collections: api::collections::Client::new(config.clone()), Err(mut poison) => {
movies: api::movies::Client::new(config.clone()), **poison.get_mut() = new_policy;
shows: api::shows::Client::new(config.clone()), self.cache_policy.clear_poison();
seasons: api::seasons::Client::new(config.clone()), }
episodes: api::episodes::Client::new(config.clone()),
} }
} }
} }
+3
View File
@@ -5,6 +5,9 @@
pub mod api; pub mod api;
pub mod model; pub mod model;
mod cache;
pub use cache::{Cache, CachePolicy, RedbCache};
mod client; mod client;
pub use client::Client; pub use client::Client;
+6 -2
View File
@@ -3,8 +3,9 @@ use core::time::Duration;
use flix_model::numbers::EpisodeNumber; use flix_model::numbers::EpisodeNumber;
use chrono::NaiveDate; use chrono::NaiveDate;
use url::Url;
use super::duration_from_minutes; 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)] #[derive(Debug, Clone, serde::Deserialize)]
@@ -18,7 +19,10 @@ pub struct Episode {
pub overview: String, pub overview: String,
/// The episode's air date /// The episode's air date
pub air_date: NaiveDate, pub air_date: NaiveDate,
/// The movie's runtime /// The episode's runtime
#[serde(deserialize_with = "duration_from_minutes")] #[serde(deserialize_with = "duration_from_minutes")]
pub runtime: Duration, pub runtime: Duration,
/// The episode's still path
#[serde(deserialize_with = "still_url_from_path")]
pub still_path: Option<Url>,
} }
+21 -1
View File
@@ -1,8 +1,10 @@
//! Deserializable types from the TMDB API //! Deserializable types from the TMDB API
use core::str::FromStr;
use core::time::Duration; use core::time::Duration;
use serde::{Deserialize, Deserializer}; use serde::{Deserialize, Deserializer};
use url::Url;
pub mod id; pub mod id;
@@ -22,6 +24,24 @@ fn duration_from_minutes<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where where
D: Deserializer<'de>, D: Deserializer<'de>,
{ {
let minutes = u64::deserialize(deserializer)?; let minutes = u64::deserialize(deserializer).unwrap_or(0);
Ok(Duration::from_secs(minutes.saturating_mul(60))) Ok(Duration::from_secs(minutes.saturating_mul(60)))
} }
fn still_url_from_path<'de, D>(deserializer: D) -> Result<Option<Url>, D::Error>
where
D: Deserializer<'de>,
{
const TMDB_IMAGE_BASE: &str = "http://image.tmdb.org/t/p/";
const TMDB_IMAGE_QUALITY: &str = "original";
let path = Option::<&str>::deserialize(deserializer)?;
Ok(path.and_then(|path| {
Url::from_str(&format!(
"{}{}{}",
TMDB_IMAGE_BASE, TMDB_IMAGE_QUALITY, path
))
.ok()
}))
}
Executable
+1143
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
toml-version = "v1.0.0"
[format.rules]
indent-style = "tab"
indent-width = 4
# Required for rust <1.94
[[schemas]]
toml-version = "v1.0.0"
path = "tombi://www.schemastore.org/cargo.json"
include = ["Cargo.toml"]