4 Commits

50 changed files with 4238 additions and 1308 deletions
+4
View File
@@ -5,3 +5,7 @@
# Rust # Rust
/target /target
# Flix
flix.db
flix.redb
-8
View File
@@ -1,18 +1,10 @@
{ {
"project_name": null,
"auto_install_extensions": {
"tombi": true,
"cargo-appraiser": true,
},
"languages": { "languages": {
"TOML": { "TOML": {
"format_on_save": "on", "format_on_save": "on",
"formatter": { "language_server": { "name": "tombi" } }, "formatter": { "language_server": { "name": "tombi" } },
}, },
}, },
"lsp": { "lsp": {
"rust-analyzer": { "rust-analyzer": {
"initialization_options": { "initialization_options": {
+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
+877 -503
View File
File diff suppressed because it is too large Load Diff
+25 -12
View File
@@ -3,40 +3,53 @@ resolver = "2"
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
edition = "2024"
rust-version = "1.87.0"
license-file = "LICENSE.md" license-file = "LICENSE.md"
edition = "2024"
rust-version = "1.89.0"
[workspace.dependencies] [workspace.dependencies]
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 } anyhow = { version = "^1", default-features = false }
async-stream = { version = "^0.3", default-features = false } async-stream = { version = "^0.3", default-features = false }
bytes = { version = "^1", default-features = false }
chrono = { version = "^0.4", default-features = false } chrono = { version = "^0.4", default-features = false }
clap = { version = "^4", default-features = false } clap = { version = "^4", default-features = false }
flix = { path = "crates/flix", version = "=0.0.16", default-features = false } console = { version = "^0.16", default-features = false }
flix-cli = { path = "crates/cli", version = "=0.0.16", default-features = false } dialoguer = { version = "^0.12", default-features = false }
flix-db = { path = "crates/db", version = "=0.0.16", default-features = false } either = { version = "^1", default-features = false }
flix-fs = { path = "crates/fs", version = "=0.0.16", default-features = false }
flix-model = { path = "crates/model", version = "=0.0.16", default-features = false }
flix-tmdb = { path = "crates/tmdb", version = "=0.0.16", default-features = false }
futures = { version = "^0.3", default-features = false } futures = { version = "^0.3", default-features = false }
governor = { version = "^0.10", default-features = false } governor = { version = "^0.10", default-features = false }
indicatif = { version = "^0.18", default-features = false }
itertools = { version = "^0.14", default-features = false } itertools = { version = "^0.14", default-features = false }
nonzero_ext = { version = "^0.3", default-features = false } nonzero_ext = { version = "^0.3", default-features = false }
redb = { version = "^4", default-features = false }
regex = { version = "^1", default-features = false } regex = { version = "^1", default-features = false }
reqwest = { version = "^0.13", default-features = false } reqwest = { version = "^0.13", default-features = false }
sea-orm = { version = "2.0.0-rc.27", default-features = false }
sea-orm-migration = { version = "2.0.0-rc.27", default-features = false }
seamantic = { version = "^0.0.11", default-features = false }
serde = { version = "^1", default-features = false } serde = { version = "^1", default-features = false }
serde_json = { version = "^1", default-features = false }
serde_test = { version = "^1", default-features = false } serde_test = { version = "^1", default-features = false }
thiserror = { version = "^2", default-features = false } thiserror = { version = "^2", default-features = false }
tokio = { version = "^1", default-features = false } tokio = { version = "^1", default-features = false }
tokio-stream = { version = "^0.1", default-features = false } tokio-stream = { version = "^0.1", default-features = false }
toml = { version = "^0.9", default-features = false } toml = { version = "^1", default-features = false }
tracing = { version = "^0.1", default-features = false } tracing = { version = "^0.1", default-features = false }
tracing-subscriber = { version = "^0.3", default-features = false } tracing-subscriber = { version = "^0.3", default-features = false }
url = { version = "^2", default-features = false } url = { version = "^2", default-features = false }
url-macro = { version = "^0.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"
+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.87 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)
}
+8 -4
View File
@@ -1,13 +1,15 @@
[package] [package]
name = "flix-cli" name = "flix-cli"
version = "0.0.16" version = "0.0.19"
edition.workspace = true license-file.workspace = true
rust-version.workspace = true
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"
license-file.workspace = true
categories = ["command-line-utilities"] categories = ["command-line-utilities"]
edition.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"]
@@ -53,4 +55,6 @@ unwrap_used = "deny"
[lints.rust] [lints.rust]
arithmetic_overflow = "forbid" arithmetic_overflow = "forbid"
missing_docs = "forbid"
unsafe_code = "forbid" unsafe_code = "forbid"
unused_doc_comments = "forbid"
+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>
``` ```
+19 -1
View File
@@ -1,12 +1,30 @@
use flix::model::numbers::{EpisodeNumber, SeasonNumber};
use chrono::NaiveDate;
use clap::Subcommand; use clap::Subcommand;
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum AddCommand { pub enum AddCommand {
/// Process a flix collection /// Add a flix collection
Collection { Collection {
#[arg(value_name = "TITLE")] #[arg(value_name = "TITLE")]
title: String, title: String,
#[arg(value_name = "OVERVIEW")] #[arg(value_name = "OVERVIEW")]
overview: String, 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,
},
} }
+31 -36
View File
@@ -1,7 +1,7 @@
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 flix;
pub mod tmdb; pub mod tmdb;
@@ -10,11 +10,20 @@ pub mod tmdb;
#[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
@@ -38,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()
@@ -51,12 +64,26 @@ 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: AddCommand, command: AddCommand,
}, },
@@ -65,23 +92,6 @@ pub enum Command {
#[command(subcommand)] #[command(subcommand)]
command: UpdateCommand, command: UpdateCommand,
}, },
/// Delete an existing item in the database
Delete {
#[command(subcommand)]
command: DeleteCommand,
},
/// 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)]
@@ -124,18 +134,3 @@ impl From<tmdb::Command> for UpdateCommand {
Self::Tmdb { command: value } Self::Tmdb { command: value }
} }
} }
#[derive(Subcommand)]
pub enum DeleteCommand {
/// Use the TMDB backend
Tmdb {
#[command(subcommand)]
command: tmdb::Command,
},
}
impl From<tmdb::Command> for DeleteCommand {
fn from(value: tmdb::Command) -> Self {
Self::Tmdb { command: value }
}
}
+21 -30
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::{AddCommand, Cli, Command, DeleteCommand, UpdateCommand}; 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,15 +58,20 @@ async fn exec_init(database_path: String) -> Result<()> {
Ok(()) Ok(())
} }
async fn exec_add(client: Client, database_path: String, command: AddCommand) -> 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 {
AddCommand::Flix { command } => { AddCommand::Flix { command } => {
run::flix::add(database.as_ref(), command).await?; run::flix::add(database.as_ref(), command, overrides).await?;
} }
AddCommand::Tmdb { command } => { AddCommand::Tmdb { command } => {
run::tmdb::add(client, database.as_ref(), command).await?; run::tmdb::add(client, database.as_ref(), command, overrides).await?;
} }
} }
@@ -79,22 +89,3 @@ async fn exec_update(client: Client, database_path: String, command: UpdateComma
Ok(()) Ok(())
} }
async fn exec_delete(client: Client, database_path: String, command: DeleteCommand) -> Result<()> {
_ = client;
_ = database_path;
_ = command;
unimplemented!()
}
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!()
}
+66 -9
View File
@@ -1,23 +1,35 @@
use flix::db::entity; use flix::db::entity;
use flix::model::id::CollectionId; use flix::model::id::{CollectionId, ShowId};
use flix::model::numbers::{EpisodeNumber, SeasonNumber};
use flix::model::text; use flix::model::text;
use anyhow::Result; use anyhow::Result;
use sea_orm::ActiveValue::{NotSet, Set}; use sea_orm::ActiveValue::{NotSet, Set};
use sea_orm::{ActiveModelTrait, DatabaseConnection, DbErr, TransactionError, TransactionTrait}; use sea_orm::{ActiveModelTrait, DatabaseConnection, DbErr, TransactionError, TransactionTrait};
use crate::cli::AddOverrides;
use crate::cli::flix::AddCommand; use crate::cli::flix::AddCommand;
pub async fn add(db: &DatabaseConnection, command: AddCommand) -> Result<()> { pub async fn add(
db: &DatabaseConnection,
command: AddCommand,
overrides: AddOverrides,
) -> Result<()> {
match command { match command {
AddCommand::Collection { title, overview } => { AddCommand::Collection { title, overview } => {
let result: Result<CollectionId, TransactionError<DbErr>> = db let result: Result<CollectionId, TransactionError<DbErr>> = db
.transaction(|txn| { .transaction(|txn| {
let title = title.clone(); let title = overrides.title.unwrap_or_else(|| title.clone());
let sort_title = text::make_sortable_title(&title); let sort_title = overrides
let fs_slug = text::make_fs_slug(&title); .sort_title
let web_slug = text::make_web_slug(&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 { Box::pin(async move {
let flix = entity::info::collections::ActiveModel { let flix = entity::info::collections::ActiveModel {
@@ -36,12 +48,57 @@ pub async fn add(db: &DatabaseConnection, command: AddCommand) -> Result<()> {
}) })
.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: {} [{}]", title, flix_id.into_raw()); 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(()) Ok(())
} }
+626 -59
View File
@@ -16,9 +16,15 @@ 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);
@@ -36,18 +42,25 @@ 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 = collection.title.clone(); let title = overrides.title.unwrap_or(collection.title);
let sort_title = text::make_sortable_title(&title); let sort_title = overrides
let fs_slug = text::make_fs_slug(&title); .sort_title
let web_slug = text::make_web_slug(&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), sort_title: Set(sort_title),
fs_slug: Set(fs_slug), fs_slug: Set(fs_slug),
@@ -70,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: {} [{}]", title, flix_id.into_raw()); println!("Created Collection: {}", title);
Ok(()) Ok(())
} }
@@ -93,19 +106,26 @@ 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 = movie.title.clone(); let title = overrides.title.unwrap_or(movie.title);
let year = movie.release_date.year(); let year = movie.release_date.year();
let sort_title = text::make_sortable_title(&title); let sort_title = overrides
let fs_slug = text::make_fs_slug_year(&title, year); .sort_title
let web_slug = text::make_web_slug_year(&title, year); .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),
@@ -131,17 +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!( println!("Created Movie: {} ({})", title, year);
"Created Movie: {} ({}) [{}]",
title,
year,
flix_id.into_raw(),
);
Ok(()) Ok(())
} }
@@ -161,9 +176,6 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
let mut seasons = Vec::new(); let mut seasons = Vec::new();
let mut episodes = HashMap::new(); let mut episodes = HashMap::new();
let title = show.title.clone();
let year = show.first_air_date.year();
for season in 1..=show.number_of_seasons { for season in 1..=show.number_of_seasons {
let season = SeasonNumber::new(season); let season = SeasonNumber::new(season);
let season = match client let season = match client
@@ -198,18 +210,22 @@ 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 episode = EpisodeNumber::new(episode); let episode = EpisodeNumber::new(episode);
let Ok(episode) = client 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);
} }
@@ -218,16 +234,26 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
seasons.push(season); seasons.push(season);
} }
let sort_title = text::make_sortable_title(&show.title); let title = overrides.title.unwrap_or(show.title);
let fs_slug = text::make_fs_slug_year(&show.title, show.first_air_date.year()); let year = show.first_air_date.year();
let web_slug = text::make_web_slug_year(&show.title, 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),
@@ -302,17 +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!( println!("Created Show: {} ({})", title, year);
"Created Show: {} ({}) [{}]",
title,
year,
flix_id.into_raw()
);
Ok(()) Ok(())
} }
@@ -353,18 +374,22 @@ 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 episode = EpisodeNumber::new(episode); let episode = EpisodeNumber::new(episode);
let Ok(episode) = client 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);
} }
@@ -424,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)?,
}; };
@@ -514,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)?,
}; };
@@ -541,9 +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")
// match command {
// Command::Collection { id } => {
// let id = TmdbCollectionId::from_raw(id);
// let collection = entity::tmdb::collections::Entity::find_by_id(id)
// .one(db)
// .await?;
// if collection.is_some() {
// bail!("collection already exists");
// }
// let collection = client
// .collections()
// .get_details(id, None)
// .await
// .with_context(|| format!("collections().get_details({})", id.into_raw()))?;
// let title = overrides.title.unwrap_or(collection.title);
// let sort_title = overrides
// .sort_title
// .unwrap_or_else(|| text::make_sortable_title(&title));
// let fs_slug = overrides
// .fs_slug
// .unwrap_or_else(|| text::make_fs_slug(&title));
// let web_slug = overrides
// .web_slug
// .unwrap_or_else(|| text::make_web_slug(&title));
// let result: Result<CollectionId, TransactionError<DbErr>> = db
// .transaction(|txn| {
// let title = title.clone();
// Box::pin(async move {
// let flix = entity::info::collections::ActiveModel {
// id: NotSet,
// title: Set(title),
// overview: Set(collection.overview),
// sort_title: Set(sort_title),
// fs_slug: Set(fs_slug),
// web_slug: Set(web_slug),
// }
// .insert(txn)
// .await?;
// entity::tmdb::collections::ActiveModel {
// tmdb_id: Set(id),
// flix_id: Set(flix.id),
// last_update: Set(Utc::now()),
// movie_count: Set(collection.movies.len().try_into().unwrap_or(0)),
// }
// .insert(txn)
// .await?;
// Ok(flix.id)
// })
// })
// .await;
// let flix_id = match result {
// Ok(id) => id,
// Err(TransactionError::Connection(err)) => Err(err)?,
// Err(TransactionError::Transaction(err)) => Err(err)?,
// };
// println!("Created Collection: {}", title, flix_id.into_raw());
// Ok(())
// }
// Command::Movie { id } => {
// let id = TmdbMovieId::from_raw(id);
// let movie = entity::tmdb::movies::Entity::find_by_id(id).one(db).await?;
// if movie.is_some() {
// bail!("movie already exists");
// }
// let movie = client
// .movies()
// .get_details(id, None)
// .await
// .with_context(|| format!("movies().get_details({})", id.into_raw()))?;
// let title = overrides.title.unwrap_or(movie.title);
// let year = movie.release_date.year();
// let sort_title = overrides
// .sort_title
// .unwrap_or_else(|| text::make_sortable_title(&title));
// let fs_slug = overrides
// .fs_slug
// .unwrap_or_else(|| text::make_fs_slug_year(&title, year));
// let web_slug = overrides
// .web_slug
// .unwrap_or_else(|| text::make_web_slug_year(&title, year));
// let result: Result<MovieId, TransactionError<DbErr>> = db
// .transaction(|txn| {
// let title = title.clone();
// Box::pin(async move {
// let flix = entity::info::movies::ActiveModel {
// id: NotSet,
// title: Set(title),
// tagline: Set(movie.tagline),
// overview: Set(movie.overview),
// date: Set(movie.release_date),
// sort_title: Set(sort_title),
// fs_slug: Set(fs_slug),
// web_slug: Set(web_slug),
// }
// .insert(txn)
// .await?;
// entity::tmdb::movies::ActiveModel {
// tmdb_id: Set(id),
// flix_id: Set(flix.id),
// last_update: Set(Utc::now()),
// runtime: Set(movie.runtime.into()),
// collection_id: Set(movie.collection.map(|c| c.id)),
// }
// .insert(txn)
// .await?;
// Ok(flix.id)
// })
// })
// .await;
// let flix_id = match result {
// Ok(id) => id,
// Err(TransactionError::Connection(err)) => Err(err)?,
// Err(TransactionError::Transaction(err)) => Err(err)?,
// };
// println!(
// "Created Movie: {} ({})",
// title,
// year,
// flix_id.into_raw(),
// );
// Ok(())
// }
// Command::Show { id } => {
// let id = TmdbShowId::from_raw(id);
// let show = entity::tmdb::shows::Entity::find_by_id(id).one(db).await?;
// if show.is_some() {
// bail!("show already exists");
// }
// let show = client
// .shows()
// .get_details(id, None)
// .await
// .with_context(|| format!("shows().get_details({})", id.into_raw()))?;
// let mut seasons = Vec::new();
// let mut episodes = HashMap::new();
// for season in 1..=show.number_of_seasons {
// let season = SeasonNumber::new(season);
// let season = match client
// .seasons()
// .get_details(id, season, None)
// .await
// .with_context(|| {
// format!("seasons().get_details({}, {})", id.into_raw(), season)
// }) {
// Ok(season) => season,
// Err(err) => {
// eprintln!("{err:?}");
// continue;
// }
// };
// if season.air_date > Utc::now().naive_utc().date() {
// eprintln!(
// "skipping season ({}, {})",
// id.into_raw(),
// season.season_number
// );
// break;
// }
// let Ok(number_of_episodes) = u32::try_from(season.episodes.len()) else {
// bail!(
// "could not convert {} to an EpisodeNumber",
// season.episodes.len()
// )
// };
// let mut season_episodes = Vec::new();
// for episode in 1..=number_of_episodes {
// let episode = EpisodeNumber::new(episode);
// let Ok(episode) = client
// .episodes()
// .get_details(id, season.season_number, episode, None)
// .await
// else {
// eprintln!(
// "skipping episode ({}, {}, {})",
// id.into_raw(),
// season.season_number,
// episode
// );
// break;
// };
// season_episodes.push(episode);
// }
// episodes.insert(season.season_number, season_episodes);
// seasons.push(season);
// }
// let title = overrides.title.unwrap_or(show.title);
// let year = show.first_air_date.year();
// let sort_title = overrides
// .sort_title
// .unwrap_or_else(|| text::make_sortable_title(&title));
// let fs_slug = overrides
// .fs_slug
// .unwrap_or_else(|| text::make_fs_slug_year(&title, year));
// let web_slug = overrides
// .web_slug
// .unwrap_or_else(|| text::make_web_slug_year(&title, year));
// let result: Result<ShowId, TransactionError<DbErr>> = db
// .transaction(|txn| {
// let title = title.clone();
// Box::pin(async move {
// let flix = entity::info::shows::ActiveModel {
// id: NotSet,
// title: Set(title),
// tagline: Set(show.tagline),
// overview: Set(show.overview),
// date: Set(show.first_air_date),
// sort_title: Set(sort_title),
// fs_slug: Set(fs_slug),
// web_slug: Set(web_slug),
// }
// .insert(txn)
// .await?;
// entity::tmdb::shows::ActiveModel {
// tmdb_id: Set(id),
// flix_id: Set(flix.id),
// last_update: Set(Utc::now()),
// number_of_seasons: Set(show.number_of_seasons),
// }
// .insert(txn)
// .await?;
// for season in seasons {
// entity::info::seasons::ActiveModel {
// show_id: Set(flix.id),
// season_number: Set(season.season_number),
// title: Set(season.title),
// overview: Set(season.overview),
// date: Set(season.air_date),
// }
// .insert(txn)
// .await?;
// entity::tmdb::seasons::ActiveModel {
// tmdb_show: Set(id),
// tmdb_season: Set(season.season_number),
// flix_show: Set(flix.id),
// flix_season: Set(season.season_number),
// last_update: Set(Utc::now()),
// }
// .insert(txn)
// .await?;
// }
// for (season, episodes) in episodes {
// for episode in episodes {
// entity::info::episodes::ActiveModel {
// show_id: Set(flix.id),
// season_number: Set(season),
// episode_number: Set(episode.episode_number),
// title: Set(episode.title),
// overview: Set(episode.overview),
// date: Set(episode.air_date),
// }
// .insert(txn)
// .await?;
// entity::tmdb::episodes::ActiveModel {
// tmdb_show: Set(id),
// tmdb_season: Set(season),
// tmdb_episode: Set(episode.episode_number),
// flix_show: Set(flix.id),
// flix_season: Set(season),
// flix_episode: Set(episode.episode_number),
// last_update: Set(Utc::now()),
// runtime: Set(episode.runtime.into()),
// }
// .insert(txn)
// .await?;
// }
// }
// Ok(flix.id)
// })
// })
// .await;
// let flix_id = match result {
// Ok(id) => id,
// Err(TransactionError::Connection(err)) => Err(err)?,
// Err(TransactionError::Transaction(err)) => Err(err)?,
// };
// println!(
// "Created Show: {} ({})",
// title,
// year,
// flix_id.into_raw()
// );
// Ok(())
// }
// Command::Season { id, season } => {
// let id = TmdbShowId::from_raw(id);
// let season_number = season;
// let Some(show) = entity::tmdb::shows::Entity::find_by_id(id).one(db).await? else {
// bail!("show does not exists");
// };
// let season = entity::tmdb::seasons::Entity::find_by_id((id, season))
// .one(db)
// .await?;
// if season.is_some() {
// bail!("season already exists");
// }
// let season = client
// .seasons()
// .get_details(id, season_number, None)
// .await
// .with_context(|| {
// format!(
// "seasons().get_details({}, {})",
// id.into_raw(),
// season_number
// )
// })?;
// let mut episodes = Vec::new();
// let Ok(number_of_episodes) = u32::try_from(season.episodes.len()) else {
// bail!(
// "could not convert {} to an EpisodeNumber",
// season.episodes.len()
// )
// };
// for episode in 1..=number_of_episodes {
// let episode = EpisodeNumber::new(episode);
// let Ok(episode) = client
// .episodes()
// .get_details(id, season.season_number, episode, None)
// .await
// else {
// eprintln!(
// "skipping episode ({}, {}, {})",
// id.into_raw(),
// season.season_number,
// episode
// );
// break;
// };
// episodes.push(episode);
// }
// let result: Result<(), TransactionError<DbErr>> = db
// .transaction(|txn| {
// Box::pin(async move {
// entity::info::seasons::ActiveModel {
// show_id: Set(show.flix_id),
// season_number: Set(season_number),
// title: Set(season.title),
// overview: Set(season.overview),
// date: Set(season.air_date),
// }
// .insert(txn)
// .await?;
// entity::tmdb::seasons::ActiveModel {
// tmdb_show: Set(show.tmdb_id),
// tmdb_season: Set(season_number),
// flix_show: Set(show.flix_id),
// flix_season: Set(season_number),
// last_update: Set(Utc::now()),
// }
// .insert(txn)
// .await?;
// for episode in episodes {
// entity::info::episodes::ActiveModel {
// show_id: Set(show.flix_id),
// season_number: Set(season_number),
// episode_number: Set(episode.episode_number),
// title: Set(episode.title),
// overview: Set(episode.overview),
// date: Set(episode.air_date),
// }
// .insert(txn)
// .await?;
// entity::tmdb::episodes::ActiveModel {
// tmdb_show: Set(show.tmdb_id),
// tmdb_season: Set(season_number),
// tmdb_episode: Set(episode.episode_number),
// flix_show: Set(show.flix_id),
// flix_season: Set(season_number),
// flix_episode: Set(episode.episode_number),
// last_update: Set(Utc::now()),
// runtime: Set(episode.runtime.into()),
// }
// .insert(txn)
// .await?;
// }
// Ok(())
// })
// })
// .await;
// match result {
// Ok(_) => (),
// Err(TransactionError::Connection(err)) => Err(err)?,
// Err(TransactionError::Transaction(err)) => Err(err)?,
// };
// println!(
// "Created Season: {} S{}",
// show.flix_id.into_raw(),
// season_number
// );
// Ok(())
// }
// Command::Episode {
// id,
// season,
// episode,
// episodes,
// } => {
// let id = TmdbShowId::from_raw(id);
// let season_number = season;
// let Some(show) = entity::tmdb::shows::Entity::find_by_id(id).one(db).await? else {
// bail!("show does not exists");
// };
// let Some(_) = entity::tmdb::seasons::Entity::find_by_id((id, season))
// .one(db)
// .await?
// else {
// bail!("season does not exists");
// };
// async fn fetch_episode(
// client: &Client,
// db: &DatabaseConnection,
// flix_id: ShowId,
// tmdb_id: TmdbShowId,
// id: TmdbShowId,
// season: SeasonNumber,
// episode: EpisodeNumber,
// ) -> Result<()> {
// let episode_number = episode;
// let episode = entity::tmdb::episodes::Entity::find_by_id((id, season, episode))
// .one(db)
// .await?;
// if episode.is_some() {
// bail!("episode already exists");
// }
// let episode = client
// .episodes()
// .get_details(id, season, episode_number, None)
// .await
// .with_context(|| {
// format!("episodes().get_details({}, {})", id.into_raw(), season)
// })?;
// let result: Result<(), TransactionError<DbErr>> = db
// .transaction(|txn| {
// Box::pin(async move {
// entity::info::episodes::ActiveModel {
// show_id: Set(flix_id),
// season_number: Set(season),
// episode_number: Set(episode_number),
// title: Set(episode.title),
// overview: Set(episode.overview),
// date: Set(episode.air_date),
// }
// .insert(txn)
// .await?;
// entity::tmdb::episodes::ActiveModel {
// tmdb_show: Set(tmdb_id),
// tmdb_season: Set(season),
// tmdb_episode: Set(episode_number),
// flix_show: Set(flix_id),
// flix_season: Set(season),
// flix_episode: Set(episode_number),
// last_update: Set(Utc::now()),
// runtime: Set(episode.runtime.into()),
// }
// .insert(txn)
// .await?;
// Ok(())
// })
// })
// .await;
// match result {
// Ok(_) => (),
// Err(TransactionError::Connection(err)) => Err(err)?,
// Err(TransactionError::Transaction(err)) => Err(err)?,
// };
// println!(
// "Created Episode: {} S{}E{}",
// flix_id.into_raw(),
// season,
// episode_number
// );
// Ok(())
// }
// let flix_id = show.flix_id;
// let tmdb_id = show.tmdb_id;
// fetch_episode(&client, db, flix_id, tmdb_id, id, season_number, episode).await?;
// for episode in episodes {
// fetch_episode(&client, db, flix_id, tmdb_id, id, season_number, episode).await?;
// }
// Ok(())
// }
// }
} }
+8 -5
View File
@@ -1,13 +1,15 @@
[package] [package]
name = "flix-db" name = "flix-db"
version = "0.0.16" version = "0.0.19"
edition.workspace = true license-file.workspace = true
rust-version.workspace = true
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"
license-file.workspace = true
categories = [] categories = []
edition.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"]
@@ -15,7 +17,6 @@ rustdoc-args = ["--cfg", "docsrs"]
[dependencies] [dependencies]
chrono = { workspace = true } chrono = { workspace = true }
flix-model = { workspace = true } flix-model = { workspace = true }
flix-tmdb = { workspace = true, features = ["sea-orm"], optional = true }
sea-orm = { workspace = true, features = [ sea-orm = { workspace = true, features = [
"entity-registry", "entity-registry",
"schema-sync", "schema-sync",
@@ -24,6 +25,8 @@ sea-orm = { workspace = true, features = [
sea-orm-migration = { workspace = true } sea-orm-migration = { workspace = true }
seamantic = { workspace = true, features = ["sqlite"] } 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 = [
+15 -5
View File
@@ -4,6 +4,7 @@
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 chrono::{DateTime, Utc};
@@ -20,7 +21,9 @@ pub mod libraries {
/// The library's directory /// The library's directory
pub directory: PathBytes, pub directory: PathBytes,
/// The library's last scan data /// The library's last scan data
pub last_scan: Option<DateTime<Utc>>, 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)] #[sea_orm(has_many)]
@@ -415,7 +418,8 @@ pub mod test {
$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: Set(None), last_scan_date: Set(None),
last_scan_duration: Set(None),
} }
.insert($db) .insert($db)
.await .await
@@ -522,10 +526,13 @@ 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 chrono::NaiveDate;
use sea_orm::ActiveValue::{NotSet, Set}; use sea_orm::ActiveValue::{NotSet, Set};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
@@ -566,7 +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, noneable!(last_scan, 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_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),+)?)
@@ -578,7 +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: notsettable!(last_scan, 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_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
}; };
} }
@@ -588,7 +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); assert_library!(&db, 5, Success; last_scan_date);
assert_library!(&db, 6, Success; last_scan_duration);
} }
#[tokio::test] #[tokio::test]
+30
View File
@@ -7,6 +7,8 @@ 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)]
@@ -29,6 +31,10 @@ pub mod collections {
/// The url-safe slug /// The url-safe slug
#[sea_orm(indexed, unique)] #[sea_orm(indexed, unique)]
pub web_slug: String, 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 {}
@@ -41,6 +47,8 @@ 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)]
@@ -68,6 +76,10 @@ pub mod movies {
/// The url-safe slug /// The url-safe slug
#[sea_orm(indexed, unique)] #[sea_orm(indexed, unique)]
pub web_slug: String, 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 {}
@@ -80,6 +92,8 @@ 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)]
@@ -114,6 +128,10 @@ pub mod shows {
/// Episodes that are part of this show /// Episodes that are part of this show
#[sea_orm(has_many)] #[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 {}
@@ -127,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)]
@@ -158,6 +178,10 @@ pub mod seasons {
/// Episodes that are part of this season /// Episodes that are part of this season
#[sea_orm(has_many)] #[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 {}
@@ -171,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)]
@@ -211,6 +237,10 @@ pub mod episodes {
on_delete = "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 {}
+8 -5
View File
@@ -1,21 +1,24 @@
[package] [package]
name = "flix" name = "flix"
version = "0.0.16" version = "0.0.19"
edition.workspace = true license-file.workspace = true
rust-version.workspace = true
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"
license-file.workspace = true
categories = [] categories = []
edition.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"]
[dependencies] [dependencies]
flix-db = { workspace = true } flix-db = { workspace = true }
flix-fs = { workspace = true, optional = true }
flix-model = { workspace = true } flix-model = { workspace = true }
flix-fs = { workspace = true, optional = true }
flix-tmdb = { workspace = true, optional = true } flix-tmdb = { workspace = true, optional = true }
[features] [features]
+7 -4
View File
@@ -1,19 +1,22 @@
[package] [package]
name = "flix-fs" name = "flix-fs"
version = "0.0.16" version = "0.0.19"
edition.workspace = true license-file.workspace = true
rust-version.workspace = true
description = "Filesystem scanner for flix media" description = "Filesystem scanner for flix media"
repository = "https://github.com/QuantumShade/flix" repository = "https://github.com/QuantumShade/flix"
license-file.workspace = true
categories = [] categories = []
edition.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"]
[dependencies] [dependencies]
async-stream = { workspace = true } async-stream = { workspace = true }
either = { workspace = true }
flix-model = { workspace = true } flix-model = { workspace = true }
regex = { workspace = true, features = ["perf", "std"] } regex = { workspace = true, features = ["perf", "std"] }
thiserror = { workspace = true } thiserror = { workspace = true }
+27 -151
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 {
@@ -266,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());
} }
} }
+6 -16
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> {
@@ -135,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,
}), })),
}; };
}) })
} }
+62 -162
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;
@@ -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>,
}
+8 -16
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 {
@@ -131,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,
}), })),
}; };
}) })
} }
+10 -40
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!({
@@ -140,11 +110,11 @@ 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 {
@@ -207,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,
) { ) {
+17 -60
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 {
@@ -158,11 +113,11 @@ 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 {
@@ -185,7 +140,9 @@ impl Scanner {
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());
} }
} }
+8 -5
View File
@@ -1,13 +1,15 @@
[package] [package]
name = "flix-model" name = "flix-model"
version = "0.0.16" version = "0.0.19"
edition.workspace = true license-file.workspace = true
rust-version.workspace = true
description = "Core types for flix data" description = "Core types for flix data"
repository = "https://github.com/QuantumShade/flix" repository = "https://github.com/QuantumShade/flix"
license-file.workspace = true
categories = [] categories = []
edition.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"]
@@ -15,9 +17,10 @@ rustdoc-args = ["--cfg", "docsrs"]
[dependencies] [dependencies]
itertools = { workspace = true } itertools = { workspace = true }
seamantic = { workspace = true } seamantic = { workspace = true }
serde = { workspace = true, features = ["derive", "std"], optional = true }
thiserror = { workspace = true } thiserror = { workspace = true }
serde = { workspace = true, features = ["derive", "std"], optional = true }
[features] [features]
default = [] default = []
serde = ["dep:serde"] serde = ["dep:serde"]
+18 -15
View File
@@ -12,22 +12,25 @@ fn split_normalized_words(input: &str) -> impl Iterator<Item = String> {
panic!("Input is not ASCII: {input}"); panic!("Input is not ASCII: {input}");
} }
input.split_ascii_whitespace().map(|s| { input
let chars = s .split_ascii_whitespace()
.chars() .map(|s| {
.filter(|c| c.is_ascii_alphanumeric() || *c == '-') let chars = s
.map(|c| c.to_ascii_lowercase()); .chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.map(|c| c.to_ascii_lowercase());
if s.len() > 4 if s.len() > 4
&& s.len().is_multiple_of(2) && s.len().is_multiple_of(2)
&& chars.clone().tuples().all(|(l, r)| l != '.' && r == '.') && chars.clone().tuples().all(|(l, r)| l != '.' && r == '.')
{ {
// Collapse acronym // Collapse acronym
chars.tuples().map(|(l, _)| l).collect() chars.tuples().map(|(l, _)| l).collect()
} else { } else {
chars.collect() chars.collect()
} }
}) })
.filter(|part: &String| !part.is_empty())
} }
fn split_leading_article<I: Iterator<Item = String>>(iter: I) -> (Option<String>, Peekable<I>) { fn split_leading_article<I: Iterator<Item = String>>(iter: I) -> (Option<String>, Peekable<I>) {
+12 -6
View File
@@ -1,29 +1,35 @@
[package] [package]
name = "flix-tmdb" name = "flix-tmdb"
version = "0.0.16" version = "0.0.19"
edition.workspace = true license-file.workspace = true
rust-version.workspace = true
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"
license-file.workspace = true
categories = [] categories = []
edition.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"]
[dependencies] [dependencies]
bytes = { workspace = true }
chrono = { workspace = true, features = ["serde"] } chrono = { workspace = true, features = ["serde"] }
flix-model = { workspace = true, features = ["serde"] } flix-model = { workspace = true, features = ["serde"] }
governor = { workspace = true, features = ["jitter", "std"] } governor = { workspace = true, features = ["jitter", "std"] }
nonzero_ext = { workspace = true } nonzero_ext = { workspace = true }
reqwest = { workspace = true, features = ["json", "query", "rustls"] } redb = { workspace = true }
sea-orm = { workspace = true, optional = 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 }
sea-orm = { workspace = true, optional = true }
[dev-dependencies] [dev-dependencies]
serde_test = { workspace = true } serde_test = { 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
+6
View File
@@ -3,3 +3,9 @@ toml-version = "v1.0.0"
[format.rules] [format.rules]
indent-style = "tab" indent-style = "tab"
indent-width = 4 indent-width = 4
# Required for rust <1.94
[[schemas]]
toml-version = "v1.0.0"
path = "tombi://www.schemastore.org/cargo.json"
include = ["Cargo.toml"]