4 Commits

50 changed files with 4238 additions and 1308 deletions
+4
View File
@@ -5,3 +5,7 @@
# Rust
/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": {
"TOML": {
"format_on_save": "on",
"formatter": { "language_server": { "name": "tombi" } },
},
},
"lsp": {
"rust-analyzer": {
"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 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/*"]
[workspace.package]
edition = "2024"
rust-version = "1.87.0"
license-file = "LICENSE.md"
edition = "2024"
rust-version = "1.89.0"
[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 }
async-stream = { version = "^0.3", default-features = false }
bytes = { version = "^1", default-features = false }
chrono = { version = "^0.4", default-features = false }
clap = { version = "^4", default-features = false }
flix = { path = "crates/flix", version = "=0.0.16", default-features = false }
flix-cli = { path = "crates/cli", version = "=0.0.16", default-features = false }
flix-db = { path = "crates/db", version = "=0.0.16", 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 }
console = { version = "^0.16", default-features = false }
dialoguer = { version = "^0.12", default-features = false }
either = { version = "^1", default-features = false }
futures = { version = "^0.3", default-features = false }
governor = { version = "^0.10", default-features = false }
indicatif = { version = "^0.18", default-features = false }
itertools = { version = "^0.14", default-features = false }
nonzero_ext = { version = "^0.3", default-features = false }
redb = { version = "^4", default-features = false }
regex = { version = "^1", default-features = false }
reqwest = { version = "^0.13", default-features = false }
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_json = { version = "^1", default-features = false }
serde_test = { version = "^1", default-features = false }
thiserror = { version = "^2", default-features = false }
tokio = { version = "^1", default-features = false }
tokio-stream = { version = "^0.1", default-features = false }
toml = { version = "^0.9", default-features = false }
toml = { version = "^1", default-features = false }
tracing = { version = "^0.1", default-features = false }
tracing-subscriber = { version = "^0.3", default-features = false }
url = { version = "^2", default-features = false }
url-macro = { version = "^0.2", default-features = false }
walkdir = { version = "^2", default-features = false }
[workspace.lints.clippy]
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`
- clippy: `cargo hack --feature-powerset clippy -- -D warnings`
- 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`
- docs: `RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --all-features`
- install: `cargo install --path crates/cli`
- install: `cargo install --path crates/cli-mux`
- semver: `cargo semver-checks --all-features`
- 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]
name = "flix-cli"
version = "0.0.16"
edition.workspace = true
rust-version.workspace = true
version = "0.0.19"
license-file.workspace = true
description = "CLI for interacting with a flix database"
repository = "https://github.com/QuantumShade/flix"
license-file.workspace = true
categories = ["command-line-utilities"]
edition.workspace = true
rust-version.workspace = true
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
@@ -53,4 +55,6 @@ unwrap_used = "deny"
[lints.rust]
arithmetic_overflow = "forbid"
missing_docs = "forbid"
unsafe_code = "forbid"
unused_doc_comments = "forbid"
+3 -2
View File
@@ -5,7 +5,8 @@
CLI for interacting with a flix database
## Commands
```sh
cargo run -- init
cargo run -- add tmdb movie <id>
flix init
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;
#[derive(Subcommand)]
pub enum AddCommand {
/// Process a flix collection
/// Add a flix collection
Collection {
#[arg(value_name = "TITLE")]
title: String,
#[arg(value_name = "OVERVIEW")]
overview: String,
},
/// Add a flix episode
Episode {
#[arg(value_name = "SHOW_WEB_SLUG")]
show_slug: String,
#[arg(value_name = "NUMBER")]
season_number: SeasonNumber,
#[arg(value_name = "NUMBER")]
episode_number: EpisodeNumber,
#[arg(value_name = "TITLE")]
title: String,
#[arg(value_name = "OVERVIEW")]
overview: String,
#[arg(value_name = "DATE")]
air_date: NaiveDate,
},
}
+31 -36
View File
@@ -1,7 +1,7 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use clap::{Parser, Subcommand};
use clap::{Args, Parser, Subcommand};
pub mod flix;
pub mod tmdb;
@@ -10,11 +10,20 @@ pub mod tmdb;
#[command(version, about, long_about = None)]
pub struct Cli {
/// 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,
/// Use a custom cache file
#[arg(short = 'C', long, value_name = "FILE", default_value = "./flix.redb")]
cache: PathBuf,
/// 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,
/// Enable tracing
@@ -38,6 +47,10 @@ impl Cli {
}
}
pub fn cache_path(&self) -> &Path {
&self.cache
}
pub fn database_path(&self) -> Result<String> {
self.database
.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)]
pub enum Command {
/// Initialize a new database
Init,
/// Add new items to the database
Add {
#[command(flatten)]
overrides: AddOverrides,
#[command(subcommand)]
command: AddCommand,
},
@@ -65,23 +92,6 @@ pub enum Command {
#[command(subcommand)]
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)]
@@ -124,18 +134,3 @@ impl From<tmdb::Command> for UpdateCommand {
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 clap::Parser;
use tokio::fs;
mod cli;
use cli::{AddCommand, Cli, Command, DeleteCommand, UpdateCommand};
use cli::{AddCommand, Cli, Command, UpdateCommand};
mod config;
use config::Config;
use crate::cli::AddOverrides;
mod db;
mod run;
@@ -26,7 +30,9 @@ async fn main() -> Result<()> {
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 {
tracing_subscriber::fmt()
@@ -37,11 +43,10 @@ async fn main() -> Result<()> {
match cli.command() {
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::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(())
@@ -53,15 +58,20 @@ async fn exec_init(database_path: String) -> Result<()> {
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?;
match command {
AddCommand::Flix { command } => {
run::flix::add(database.as_ref(), command).await?;
run::flix::add(database.as_ref(), command, overrides).await?;
}
AddCommand::Tmdb { command } => {
run::tmdb::add(client, database.as_ref(), command).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(())
}
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::model::id::CollectionId;
use flix::model::id::{CollectionId, ShowId};
use flix::model::numbers::{EpisodeNumber, SeasonNumber};
use flix::model::text;
use anyhow::Result;
use sea_orm::ActiveValue::{NotSet, Set};
use sea_orm::{ActiveModelTrait, DatabaseConnection, DbErr, TransactionError, TransactionTrait};
use crate::cli::AddOverrides;
use crate::cli::flix::AddCommand;
pub async fn add(db: &DatabaseConnection, command: AddCommand) -> Result<()> {
pub async fn add(
db: &DatabaseConnection,
command: AddCommand,
overrides: AddOverrides,
) -> Result<()> {
match command {
AddCommand::Collection { title, overview } => {
let result: Result<CollectionId, TransactionError<DbErr>> = db
.transaction(|txn| {
let title = title.clone();
let title = overrides.title.unwrap_or_else(|| title.clone());
let sort_title = text::make_sortable_title(&title);
let fs_slug = text::make_fs_slug(&title);
let web_slug = text::make_web_slug(&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));
Box::pin(async move {
let flix = entity::info::collections::ActiveModel {
@@ -36,12 +48,57 @@ pub async fn add(db: &DatabaseConnection, command: AddCommand) -> Result<()> {
})
.await;
let flix_id = match result {
Ok(id) => id,
match result {
Ok(_) => {}
Err(TransactionError::Connection(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(())
}
+616 -49
View File
@@ -16,9 +16,15 @@ use sea_orm::{
ActiveModelTrait, DatabaseConnection, DbErr, EntityTrait, TransactionError, TransactionTrait,
};
use crate::cli::AddOverrides;
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 {
Command::Collection { id } => {
let id = TmdbCollectionId::from_raw(id);
@@ -36,18 +42,25 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
.await
.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 fs_slug = text::make_fs_slug(&title);
let web_slug = text::make_web_slug(&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(collection.title),
title: Set(title),
overview: Set(collection.overview),
sort_title: Set(sort_title),
fs_slug: Set(fs_slug),
@@ -70,12 +83,12 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
})
.await;
let flix_id = match result {
Ok(id) => id,
match result {
Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?,
};
println!("Created Collection: {} [{}]", title, flix_id.into_raw());
println!("Created Collection: {}", title);
Ok(())
}
@@ -93,19 +106,26 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
.await
.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 sort_title = text::make_sortable_title(&title);
let fs_slug = text::make_fs_slug_year(&title, year);
let web_slug = text::make_web_slug_year(&title, 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(movie.title),
title: Set(title),
tagline: Set(movie.tagline),
overview: Set(movie.overview),
date: Set(movie.release_date),
@@ -131,17 +151,12 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
})
.await;
let flix_id = match result {
Ok(id) => id,
match result {
Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?,
};
println!(
"Created Movie: {} ({}) [{}]",
title,
year,
flix_id.into_raw(),
);
println!("Created Movie: {} ({})", title, year);
Ok(())
}
@@ -161,9 +176,6 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
let mut seasons = Vec::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 {
let season = SeasonNumber::new(season);
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();
for episode in 1..=number_of_episodes {
let episode = EpisodeNumber::new(episode);
let Ok(episode) = client
let episode = match client
.episodes()
.get_details(id, season.season_number, episode, None)
.await
else {
{
Ok(value) => value,
Err(err) => {
eprintln!(
"skipping episode ({}, {}, {})",
"skipping episode ({}, {}, {}) - {}",
id.into_raw(),
season.season_number,
episode
episode,
err
);
break;
}
};
season_episodes.push(episode);
}
@@ -218,16 +234,26 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
seasons.push(season);
}
let sort_title = text::make_sortable_title(&show.title);
let fs_slug = text::make_fs_slug_year(&show.title, show.first_air_date.year());
let web_slug = text::make_web_slug_year(&show.title, show.first_air_date.year());
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(show.title),
title: Set(title),
tagline: Set(show.tagline),
overview: Set(show.overview),
date: Set(show.first_air_date),
@@ -302,17 +328,12 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
})
.await;
let flix_id = match result {
Ok(id) => id,
match result {
Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?,
};
println!(
"Created Show: {} ({}) [{}]",
title,
year,
flix_id.into_raw()
);
println!("Created Show: {} ({})", title, year);
Ok(())
}
@@ -353,18 +374,22 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
for episode in 1..=number_of_episodes {
let episode = EpisodeNumber::new(episode);
let Ok(episode) = client
let episode = match client
.episodes()
.get_details(id, season.season_number, episode, None)
.await
else {
{
Ok(value) => value,
Err(err) => {
eprintln!(
"skipping episode ({}, {}, {})",
"skipping episode ({}, {}, {}) - {}",
id.into_raw(),
season.season_number,
episode
episode,
err
);
break;
}
};
episodes.push(episode);
}
@@ -424,7 +449,7 @@ pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> R
.await;
match result {
Ok(_) => (),
Ok(_) => {}
Err(TransactionError::Connection(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;
match result {
Ok(_) => (),
Ok(_) => {}
Err(TransactionError::Connection(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;
_ = database;
_ = db;
_ = command;
unimplemented!("updates")
// match command {
// Command::Collection { id } => {
// let id = TmdbCollectionId::from_raw(id);
// let collection = entity::tmdb::collections::Entity::find_by_id(id)
// .one(db)
// .await?;
// if collection.is_some() {
// bail!("collection already exists");
// }
// let collection = client
// .collections()
// .get_details(id, None)
// .await
// .with_context(|| format!("collections().get_details({})", id.into_raw()))?;
// let title = overrides.title.unwrap_or(collection.title);
// let sort_title = overrides
// .sort_title
// .unwrap_or_else(|| text::make_sortable_title(&title));
// let fs_slug = overrides
// .fs_slug
// .unwrap_or_else(|| text::make_fs_slug(&title));
// let web_slug = overrides
// .web_slug
// .unwrap_or_else(|| text::make_web_slug(&title));
// let result: Result<CollectionId, TransactionError<DbErr>> = db
// .transaction(|txn| {
// let title = title.clone();
// Box::pin(async move {
// let flix = entity::info::collections::ActiveModel {
// id: NotSet,
// title: Set(title),
// overview: Set(collection.overview),
// sort_title: Set(sort_title),
// fs_slug: Set(fs_slug),
// web_slug: Set(web_slug),
// }
// .insert(txn)
// .await?;
// entity::tmdb::collections::ActiveModel {
// tmdb_id: Set(id),
// flix_id: Set(flix.id),
// last_update: Set(Utc::now()),
// movie_count: Set(collection.movies.len().try_into().unwrap_or(0)),
// }
// .insert(txn)
// .await?;
// Ok(flix.id)
// })
// })
// .await;
// let flix_id = match result {
// Ok(id) => id,
// Err(TransactionError::Connection(err)) => Err(err)?,
// Err(TransactionError::Transaction(err)) => Err(err)?,
// };
// println!("Created Collection: {}", title, flix_id.into_raw());
// Ok(())
// }
// Command::Movie { id } => {
// let id = TmdbMovieId::from_raw(id);
// let movie = entity::tmdb::movies::Entity::find_by_id(id).one(db).await?;
// if movie.is_some() {
// bail!("movie already exists");
// }
// let movie = client
// .movies()
// .get_details(id, None)
// .await
// .with_context(|| format!("movies().get_details({})", id.into_raw()))?;
// let title = overrides.title.unwrap_or(movie.title);
// let year = movie.release_date.year();
// let sort_title = overrides
// .sort_title
// .unwrap_or_else(|| text::make_sortable_title(&title));
// let fs_slug = overrides
// .fs_slug
// .unwrap_or_else(|| text::make_fs_slug_year(&title, year));
// let web_slug = overrides
// .web_slug
// .unwrap_or_else(|| text::make_web_slug_year(&title, year));
// let result: Result<MovieId, TransactionError<DbErr>> = db
// .transaction(|txn| {
// let title = title.clone();
// Box::pin(async move {
// let flix = entity::info::movies::ActiveModel {
// id: NotSet,
// title: Set(title),
// tagline: Set(movie.tagline),
// overview: Set(movie.overview),
// date: Set(movie.release_date),
// sort_title: Set(sort_title),
// fs_slug: Set(fs_slug),
// web_slug: Set(web_slug),
// }
// .insert(txn)
// .await?;
// entity::tmdb::movies::ActiveModel {
// tmdb_id: Set(id),
// flix_id: Set(flix.id),
// last_update: Set(Utc::now()),
// runtime: Set(movie.runtime.into()),
// collection_id: Set(movie.collection.map(|c| c.id)),
// }
// .insert(txn)
// .await?;
// Ok(flix.id)
// })
// })
// .await;
// let flix_id = match result {
// Ok(id) => id,
// Err(TransactionError::Connection(err)) => Err(err)?,
// Err(TransactionError::Transaction(err)) => Err(err)?,
// };
// println!(
// "Created Movie: {} ({})",
// title,
// year,
// flix_id.into_raw(),
// );
// Ok(())
// }
// Command::Show { id } => {
// let id = TmdbShowId::from_raw(id);
// let show = entity::tmdb::shows::Entity::find_by_id(id).one(db).await?;
// if show.is_some() {
// bail!("show already exists");
// }
// let show = client
// .shows()
// .get_details(id, None)
// .await
// .with_context(|| format!("shows().get_details({})", id.into_raw()))?;
// let mut seasons = Vec::new();
// let mut episodes = HashMap::new();
// for season in 1..=show.number_of_seasons {
// let season = SeasonNumber::new(season);
// let season = match client
// .seasons()
// .get_details(id, season, None)
// .await
// .with_context(|| {
// format!("seasons().get_details({}, {})", id.into_raw(), season)
// }) {
// Ok(season) => season,
// Err(err) => {
// eprintln!("{err:?}");
// continue;
// }
// };
// if season.air_date > Utc::now().naive_utc().date() {
// eprintln!(
// "skipping season ({}, {})",
// id.into_raw(),
// season.season_number
// );
// break;
// }
// let Ok(number_of_episodes) = u32::try_from(season.episodes.len()) else {
// bail!(
// "could not convert {} to an EpisodeNumber",
// season.episodes.len()
// )
// };
// let mut season_episodes = Vec::new();
// for episode in 1..=number_of_episodes {
// let episode = EpisodeNumber::new(episode);
// let Ok(episode) = client
// .episodes()
// .get_details(id, season.season_number, episode, None)
// .await
// else {
// eprintln!(
// "skipping episode ({}, {}, {})",
// id.into_raw(),
// season.season_number,
// episode
// );
// break;
// };
// season_episodes.push(episode);
// }
// episodes.insert(season.season_number, season_episodes);
// seasons.push(season);
// }
// let title = overrides.title.unwrap_or(show.title);
// let year = show.first_air_date.year();
// let sort_title = overrides
// .sort_title
// .unwrap_or_else(|| text::make_sortable_title(&title));
// let fs_slug = overrides
// .fs_slug
// .unwrap_or_else(|| text::make_fs_slug_year(&title, year));
// let web_slug = overrides
// .web_slug
// .unwrap_or_else(|| text::make_web_slug_year(&title, year));
// let result: Result<ShowId, TransactionError<DbErr>> = db
// .transaction(|txn| {
// let title = title.clone();
// Box::pin(async move {
// let flix = entity::info::shows::ActiveModel {
// id: NotSet,
// title: Set(title),
// tagline: Set(show.tagline),
// overview: Set(show.overview),
// date: Set(show.first_air_date),
// sort_title: Set(sort_title),
// fs_slug: Set(fs_slug),
// web_slug: Set(web_slug),
// }
// .insert(txn)
// .await?;
// entity::tmdb::shows::ActiveModel {
// tmdb_id: Set(id),
// flix_id: Set(flix.id),
// last_update: Set(Utc::now()),
// number_of_seasons: Set(show.number_of_seasons),
// }
// .insert(txn)
// .await?;
// for season in seasons {
// entity::info::seasons::ActiveModel {
// show_id: Set(flix.id),
// season_number: Set(season.season_number),
// title: Set(season.title),
// overview: Set(season.overview),
// date: Set(season.air_date),
// }
// .insert(txn)
// .await?;
// entity::tmdb::seasons::ActiveModel {
// tmdb_show: Set(id),
// tmdb_season: Set(season.season_number),
// flix_show: Set(flix.id),
// flix_season: Set(season.season_number),
// last_update: Set(Utc::now()),
// }
// .insert(txn)
// .await?;
// }
// for (season, episodes) in episodes {
// for episode in episodes {
// entity::info::episodes::ActiveModel {
// show_id: Set(flix.id),
// season_number: Set(season),
// episode_number: Set(episode.episode_number),
// title: Set(episode.title),
// overview: Set(episode.overview),
// date: Set(episode.air_date),
// }
// .insert(txn)
// .await?;
// entity::tmdb::episodes::ActiveModel {
// tmdb_show: Set(id),
// tmdb_season: Set(season),
// tmdb_episode: Set(episode.episode_number),
// flix_show: Set(flix.id),
// flix_season: Set(season),
// flix_episode: Set(episode.episode_number),
// last_update: Set(Utc::now()),
// runtime: Set(episode.runtime.into()),
// }
// .insert(txn)
// .await?;
// }
// }
// Ok(flix.id)
// })
// })
// .await;
// let flix_id = match result {
// Ok(id) => id,
// Err(TransactionError::Connection(err)) => Err(err)?,
// Err(TransactionError::Transaction(err)) => Err(err)?,
// };
// println!(
// "Created Show: {} ({})",
// title,
// year,
// flix_id.into_raw()
// );
// Ok(())
// }
// Command::Season { id, season } => {
// let id = TmdbShowId::from_raw(id);
// let season_number = season;
// let Some(show) = entity::tmdb::shows::Entity::find_by_id(id).one(db).await? else {
// bail!("show does not exists");
// };
// let season = entity::tmdb::seasons::Entity::find_by_id((id, season))
// .one(db)
// .await?;
// if season.is_some() {
// bail!("season already exists");
// }
// let season = client
// .seasons()
// .get_details(id, season_number, None)
// .await
// .with_context(|| {
// format!(
// "seasons().get_details({}, {})",
// id.into_raw(),
// season_number
// )
// })?;
// let mut episodes = Vec::new();
// let Ok(number_of_episodes) = u32::try_from(season.episodes.len()) else {
// bail!(
// "could not convert {} to an EpisodeNumber",
// season.episodes.len()
// )
// };
// for episode in 1..=number_of_episodes {
// let episode = EpisodeNumber::new(episode);
// let Ok(episode) = client
// .episodes()
// .get_details(id, season.season_number, episode, None)
// .await
// else {
// eprintln!(
// "skipping episode ({}, {}, {})",
// id.into_raw(),
// season.season_number,
// episode
// );
// break;
// };
// episodes.push(episode);
// }
// let result: Result<(), TransactionError<DbErr>> = db
// .transaction(|txn| {
// Box::pin(async move {
// entity::info::seasons::ActiveModel {
// show_id: Set(show.flix_id),
// season_number: Set(season_number),
// title: Set(season.title),
// overview: Set(season.overview),
// date: Set(season.air_date),
// }
// .insert(txn)
// .await?;
// entity::tmdb::seasons::ActiveModel {
// tmdb_show: Set(show.tmdb_id),
// tmdb_season: Set(season_number),
// flix_show: Set(show.flix_id),
// flix_season: Set(season_number),
// last_update: Set(Utc::now()),
// }
// .insert(txn)
// .await?;
// for episode in episodes {
// entity::info::episodes::ActiveModel {
// show_id: Set(show.flix_id),
// season_number: Set(season_number),
// episode_number: Set(episode.episode_number),
// title: Set(episode.title),
// overview: Set(episode.overview),
// date: Set(episode.air_date),
// }
// .insert(txn)
// .await?;
// entity::tmdb::episodes::ActiveModel {
// tmdb_show: Set(show.tmdb_id),
// tmdb_season: Set(season_number),
// tmdb_episode: Set(episode.episode_number),
// flix_show: Set(show.flix_id),
// flix_season: Set(season_number),
// flix_episode: Set(episode.episode_number),
// last_update: Set(Utc::now()),
// runtime: Set(episode.runtime.into()),
// }
// .insert(txn)
// .await?;
// }
// Ok(())
// })
// })
// .await;
// match result {
// Ok(_) => (),
// Err(TransactionError::Connection(err)) => Err(err)?,
// Err(TransactionError::Transaction(err)) => Err(err)?,
// };
// println!(
// "Created Season: {} S{}",
// show.flix_id.into_raw(),
// season_number
// );
// Ok(())
// }
// Command::Episode {
// id,
// season,
// episode,
// episodes,
// } => {
// let id = TmdbShowId::from_raw(id);
// let season_number = season;
// let Some(show) = entity::tmdb::shows::Entity::find_by_id(id).one(db).await? else {
// bail!("show does not exists");
// };
// let Some(_) = entity::tmdb::seasons::Entity::find_by_id((id, season))
// .one(db)
// .await?
// else {
// bail!("season does not exists");
// };
// async fn fetch_episode(
// client: &Client,
// db: &DatabaseConnection,
// flix_id: ShowId,
// tmdb_id: TmdbShowId,
// id: TmdbShowId,
// season: SeasonNumber,
// episode: EpisodeNumber,
// ) -> Result<()> {
// let episode_number = episode;
// let episode = entity::tmdb::episodes::Entity::find_by_id((id, season, episode))
// .one(db)
// .await?;
// if episode.is_some() {
// bail!("episode already exists");
// }
// let episode = client
// .episodes()
// .get_details(id, season, episode_number, None)
// .await
// .with_context(|| {
// format!("episodes().get_details({}, {})", id.into_raw(), season)
// })?;
// let result: Result<(), TransactionError<DbErr>> = db
// .transaction(|txn| {
// Box::pin(async move {
// entity::info::episodes::ActiveModel {
// show_id: Set(flix_id),
// season_number: Set(season),
// episode_number: Set(episode_number),
// title: Set(episode.title),
// overview: Set(episode.overview),
// date: Set(episode.air_date),
// }
// .insert(txn)
// .await?;
// entity::tmdb::episodes::ActiveModel {
// tmdb_show: Set(tmdb_id),
// tmdb_season: Set(season),
// tmdb_episode: Set(episode_number),
// flix_show: Set(flix_id),
// flix_season: Set(season),
// flix_episode: Set(episode_number),
// last_update: Set(Utc::now()),
// runtime: Set(episode.runtime.into()),
// }
// .insert(txn)
// .await?;
// Ok(())
// })
// })
// .await;
// match result {
// Ok(_) => (),
// Err(TransactionError::Connection(err)) => Err(err)?,
// Err(TransactionError::Transaction(err)) => Err(err)?,
// };
// println!(
// "Created Episode: {} S{}E{}",
// flix_id.into_raw(),
// season,
// episode_number
// );
// Ok(())
// }
// let flix_id = show.flix_id;
// let tmdb_id = show.tmdb_id;
// fetch_episode(&client, db, flix_id, tmdb_id, id, season_number, episode).await?;
// for episode in episodes {
// fetch_episode(&client, db, flix_id, tmdb_id, id, season_number, episode).await?;
// }
// Ok(())
// }
// }
}
+8 -5
View File
@@ -1,13 +1,15 @@
[package]
name = "flix-db"
version = "0.0.16"
edition.workspace = true
rust-version.workspace = true
version = "0.0.19"
license-file.workspace = true
description = "Types for storing persistent data about media"
repository = "https://github.com/QuantumShade/flix"
license-file.workspace = true
categories = []
edition.workspace = true
rust-version.workspace = true
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
@@ -15,7 +17,6 @@ rustdoc-args = ["--cfg", "docsrs"]
[dependencies]
chrono = { workspace = true }
flix-model = { workspace = true }
flix-tmdb = { workspace = true, features = ["sea-orm"], optional = true }
sea-orm = { workspace = true, features = [
"entity-registry",
"schema-sync",
@@ -24,6 +25,8 @@ sea-orm = { workspace = true, features = [
sea-orm-migration = { workspace = true }
seamantic = { workspace = true, features = ["sqlite"] }
flix-tmdb = { workspace = true, features = ["sea-orm"], optional = true }
[dev-dependencies]
sea-orm-migration = { workspace = true, features = ["runtime-tokio-rustls"] }
tokio = { version = "^1", default-features = false, features = [
+15 -5
View File
@@ -4,6 +4,7 @@
pub mod libraries {
use flix_model::id::LibraryId;
use seamantic::model::duration::Seconds;
use seamantic::model::path::PathBytes;
use chrono::{DateTime, Utc};
@@ -20,7 +21,9 @@ pub mod libraries {
/// The library's directory
pub directory: PathBytes,
/// 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
#[sea_orm(has_many)]
@@ -415,7 +418,8 @@ pub mod test {
$crate::entity::content::libraries::ActiveModel {
id: Set(::flix_model::id::LibraryId::from_raw($id)),
directory: Set(::std::path::PathBuf::new().into()),
last_scan: Set(None),
last_scan_date: Set(None),
last_scan_duration: Set(None),
}
.insert($db)
.await
@@ -522,10 +526,13 @@ pub mod test {
#[cfg(test)]
mod tests {
use core::time::Duration;
use std::path::Path;
use flix_model::id::{CollectionId, LibraryId, MovieId, ShowId};
use seamantic::model::duration::Seconds;
use chrono::NaiveDate;
use sea_orm::ActiveValue::{NotSet, Set};
use sea_orm::entity::prelude::*;
@@ -566,7 +573,8 @@ mod tests {
assert_eq!(model.id, LibraryId::from_raw($id));
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),+)?) => {
let model = assert_library!(@insert, $db, $id $(; $($skip),+)?)
@@ -578,7 +586,8 @@ mod tests {
super::libraries::ActiveModel {
id: notsettable!(id, LibraryId::from_raw($id) $(, $($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
};
}
@@ -588,7 +597,8 @@ mod tests {
assert_library!(&db, 2, Success);
assert_library!(&db, 3, Success; id);
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]
+30
View File
@@ -7,6 +7,8 @@ pub mod collections {
use sea_orm::entity::prelude::*;
use crate::entity;
/// The database representation of a flix collection
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
@@ -29,6 +31,10 @@ pub mod collections {
/// The url-safe slug
#[sea_orm(indexed, unique)]
pub web_slug: String,
/// Potential content for this collection
#[sea_orm(has_one)]
pub content: HasOne<entity::content::collections::Entity>,
}
impl ActiveModelBehavior for ActiveModel {}
@@ -41,6 +47,8 @@ pub mod movies {
use chrono::NaiveDate;
use sea_orm::entity::prelude::*;
use crate::entity;
/// The database representation of a flix movie
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
@@ -68,6 +76,10 @@ pub mod movies {
/// The url-safe slug
#[sea_orm(indexed, unique)]
pub web_slug: String,
/// Potential content for this movie
#[sea_orm(has_one)]
pub content: HasOne<entity::content::movies::Entity>,
}
impl ActiveModelBehavior for ActiveModel {}
@@ -80,6 +92,8 @@ pub mod shows {
use chrono::NaiveDate;
use sea_orm::entity::prelude::*;
use crate::entity;
/// The database representation of a flix show
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
@@ -114,6 +128,10 @@ pub mod shows {
/// Episodes that are part of this show
#[sea_orm(has_many)]
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 {}
@@ -127,6 +145,8 @@ pub mod seasons {
use chrono::NaiveDate;
use sea_orm::entity::prelude::*;
use crate::entity;
/// The database representation of a flix season
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
@@ -158,6 +178,10 @@ pub mod seasons {
/// Episodes that are part of this season
#[sea_orm(has_many)]
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 {}
@@ -171,6 +195,8 @@ pub mod episodes {
use chrono::NaiveDate;
use sea_orm::entity::prelude::*;
use crate::entity;
/// The database representation of a flix episode
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
@@ -211,6 +237,10 @@ pub mod episodes {
on_delete = "Cascade"
)]
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 {}
+8 -5
View File
@@ -1,21 +1,24 @@
[package]
name = "flix"
version = "0.0.16"
edition.workspace = true
rust-version.workspace = true
version = "0.0.19"
license-file.workspace = true
description = "Mechanisms for interacting with flix media"
repository = "https://github.com/QuantumShade/flix"
license-file.workspace = true
categories = []
edition.workspace = true
rust-version.workspace = true
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[dependencies]
flix-db = { workspace = true }
flix-fs = { workspace = true, optional = true }
flix-model = { workspace = true }
flix-fs = { workspace = true, optional = true }
flix-tmdb = { workspace = true, optional = true }
[features]
+7 -4
View File
@@ -1,19 +1,22 @@
[package]
name = "flix-fs"
version = "0.0.16"
edition.workspace = true
rust-version.workspace = true
version = "0.0.19"
license-file.workspace = true
description = "Filesystem scanner for flix media"
repository = "https://github.com/QuantumShade/flix"
license-file.workspace = true
categories = []
edition.workspace = true
rust-version.workspace = true
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[dependencies]
async-stream = { workspace = true }
either = { workspace = true }
flix-model = { workspace = true }
regex = { workspace = true, features = ["perf", "std"] }
thiserror = { workspace = true }
+27 -151
View File
@@ -4,8 +4,7 @@ use core::pin::Pin;
use std::ffi::OsStr;
use std::path::Path;
use flix_model::id::{CollectionId, MovieId, ShowId};
use flix_model::numbers::{EpisodeNumbers, SeasonNumber};
use flix_model::id::CollectionId;
use async_stream::stream;
use tokio::fs;
@@ -14,7 +13,9 @@ use tokio_stream::wrappers::ReadDirStream;
use crate::Error;
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
pub type Item = crate::Item<Scanner>;
@@ -22,74 +23,21 @@ pub type Item = crate::Item<Scanner>;
/// The scanner for collections
pub enum Scanner {
/// A scanned collection
Collection {
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the collection
id: CollectionId,
/// The file name of the poster file
poster_file_name: Option<String>,
},
Collection(CollectionScan),
/// A scanned movie
Movie {
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the movie
id: MovieId,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
Movie(MovieScan),
/// A scanned show
Show {
/// 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>,
},
Show(ShowScan),
/// A scanned episode
Season {
/// 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>,
},
Season(SeasonScan),
/// 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>,
},
Episode(EpisodeScan),
}
impl From<movie::Scanner> for Scanner {
fn from(value: movie::Scanner) -> Self {
match value {
movie::Scanner::Movie {
parent,
id,
media_file_name,
poster_file_name,
} => Self::Movie {
parent,
id,
media_file_name,
poster_file_name,
},
movie::Scanner::Movie(m) => Self::Movie(m),
}
}
}
@@ -97,37 +45,9 @@ impl From<movie::Scanner> for Scanner {
impl From<show::Scanner> for Scanner {
fn from(value: show::Scanner) -> Self {
match value {
show::Scanner::Show {
parent,
id,
poster_file_name,
} => Self::Show {
parent,
id,
poster_file_name,
},
show::Scanner::Season {
show,
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,
},
show::Scanner::Show(s) => Self::Show(s),
show::Scanner::Season(s) => Self::Season(s),
show::Scanner::Episode(e) => Self::Episode(e),
}
}
}
@@ -135,57 +55,11 @@ impl From<show::Scanner> for Scanner {
impl From<generic::Scanner> for Scanner {
fn from(value: generic::Scanner) -> Self {
match value {
generic::Scanner::Collection {
parent,
id,
poster_file_name,
} => Self::Collection {
parent,
id,
poster_file_name,
},
generic::Scanner::Movie {
parent,
id,
media_file_name,
poster_file_name,
} => Self::Movie {
parent,
id,
media_file_name,
poster_file_name,
},
generic::Scanner::Show {
parent,
id,
poster_file_name,
} => Self::Show {
parent,
id,
poster_file_name,
},
generic::Scanner::Season {
show,
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,
},
generic::Scanner::Collection(c) => Self::Collection(c),
generic::Scanner::Movie(m) => Self::Movie(m),
generic::Scanner::Show(s) => Self::Show(s),
generic::Scanner::Season(s) => Self::Season(s),
generic::Scanner::Episode(e) => Self::Episode(e),
}
}
}
@@ -194,8 +68,8 @@ impl Scanner {
/// Scan a folder for a collection
pub fn scan_collection(
path: &Path,
parent: Option<CollectionId>,
id: CollectionId,
parent_ref: Option<MediaRef<CollectionId>>,
id_ref: MediaRef<CollectionId>,
) -> Pin<Box<impl Stream<Item = Item>>> {
Box::pin(stream!({
let dirs = match fs::read_dir(path).await {
@@ -266,15 +140,17 @@ impl Scanner {
yield Item {
path: path.to_owned(),
event: Ok(Self::Collection {
parent,
id,
event: Ok(Self::Collection(CollectionScan {
parent_ref,
id_ref: id_ref.clone(),
poster_file_name,
}),
})),
};
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());
}
}
+6 -16
View File
@@ -13,6 +13,7 @@ use tokio_stream::wrappers::ReadDirStream;
use crate::Error;
use crate::macros::{is_image_extension, is_media_extension};
use crate::scanner::{EpisodeScan, MediaRef};
/// An episode item
pub type Item = crate::Item<Scanner>;
@@ -20,25 +21,14 @@ pub type Item = crate::Item<Scanner>;
/// The scanner for epispdes
pub enum Scanner {
/// A scanned episode
Episode {
/// The ID of the show this episode belongs to
show: ShowId,
/// The season this episode belongs to
season: SeasonNumber,
/// The number(s) of this episode
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>,
},
Episode(EpisodeScan),
}
impl Scanner {
/// Scan a folder for an episode
pub fn scan_episode(
path: &Path,
show: ShowId,
show_ref: MediaRef<ShowId>,
season: SeasonNumber,
episode: EpisodeNumbers,
) -> impl Stream<Item = Item> {
@@ -135,13 +125,13 @@ impl Scanner {
yield Item {
path: path.to_owned(),
event: Ok(Self::Episode {
show,
event: Ok(Self::Episode(EpisodeScan {
show_ref,
season,
episode,
media_file_name,
poster_file_name,
}),
})),
};
})
}
+58 -158
View File
@@ -6,16 +6,18 @@ use std::path::Path;
use std::sync::OnceLock;
use flix_model::id::{CollectionId, MovieId, RawId, ShowId};
use flix_model::numbers::{EpisodeNumbers, SeasonNumber};
use async_stream::stream;
use either::Either;
use regex::Regex;
use tokio::fs;
use tokio_stream::Stream;
use tokio_stream::wrappers::ReadDirStream;
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 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>;
/// The scanner for collections
#[derive(Debug)]
pub enum Scanner {
/// A scanned collection
Collection {
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the collection
id: CollectionId,
/// The file name of the poster file
poster_file_name: Option<String>,
},
Collection(CollectionScan),
/// A scanned movie
Movie {
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the movie
id: MovieId,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
Movie(MovieScan),
/// A scanned show
Show {
/// 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>,
},
Show(ShowScan),
/// A scanned episode
Season {
/// 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>,
},
Season(SeasonScan),
/// 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>,
},
Episode(EpisodeScan),
}
impl From<collection::Scanner> for Scanner {
fn from(value: collection::Scanner) -> Self {
match value {
collection::Scanner::Collection {
parent,
id,
poster_file_name,
} => Self::Collection {
parent,
id,
poster_file_name,
},
collection::Scanner::Movie {
parent,
id,
media_file_name,
poster_file_name,
} => Self::Movie {
parent,
id,
media_file_name,
poster_file_name,
},
collection::Scanner::Show {
parent,
id,
poster_file_name,
} => Self::Show {
parent,
id,
poster_file_name,
},
collection::Scanner::Season {
show,
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,
},
collection::Scanner::Collection(c) => Self::Collection(c),
collection::Scanner::Movie(m) => Self::Movie(m),
collection::Scanner::Show(s) => Self::Show(s),
collection::Scanner::Season(s) => Self::Season(s),
collection::Scanner::Episode(e) => Self::Episode(e),
}
}
}
@@ -141,17 +55,7 @@ impl From<collection::Scanner> for Scanner {
impl From<movie::Scanner> for Scanner {
fn from(value: movie::Scanner) -> Self {
match value {
movie::Scanner::Movie {
parent,
id,
media_file_name,
poster_file_name,
} => Self::Movie {
parent,
id,
media_file_name,
poster_file_name,
},
movie::Scanner::Movie(m) => Self::Movie(m),
}
}
}
@@ -159,42 +63,23 @@ impl From<movie::Scanner> for Scanner {
impl From<show::Scanner> for Scanner {
fn from(value: show::Scanner) -> Self {
match value {
show::Scanner::Show {
parent,
id,
poster_file_name,
} => Self::Show {
parent,
id,
poster_file_name,
},
show::Scanner::Season {
show,
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,
},
show::Scanner::Show(s) => Self::Show(s),
show::Scanner::Season(s) => Self::Season(s),
show::Scanner::Episode(e) => Self::Episode(e),
}
}
}
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
/// this only for detecting possibly ambiguous media:
/// - Collections
@@ -202,7 +87,7 @@ impl Scanner {
/// - Shows
pub fn scan_detect_folder(
path: &Path,
parent: Option<CollectionId>,
parent: Option<MediaRef<CollectionId>>,
) -> impl Stream<Item = Item> {
enum MediaType {
Collection,
@@ -211,7 +96,7 @@ impl Scanner {
}
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}"))
});
let season_folder_re = SEASON_FOLDER_REGEX.get_or_init(|| {
@@ -227,17 +112,24 @@ impl Scanner {
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('[')
.and_then(|(_, s)| s.split_once(']'))
.map(|(s, _)| s.parse::<RawId>())
else {
{
let Ok(id) = id_str.parse::<RawId>() else {
yield Item {
path: path.to_owned(),
event: Err(Error::UnexpectedFolder),
};
return;
};
Either::Left(id)
} else {
Either::Right(flix_model::text::normalize_fs_name(dir_name))
};
let media_type: MediaType;
if media_folder_re.is_match(dir_name) {
@@ -306,24 +198,32 @@ impl Scanner {
match media_type {
MediaType::Collection => {
for await event in collection::Scanner::scan_collection(
path,
parent,
CollectionId::from_raw(id),
) {
let id = match media_id {
Either::Left(raw) => MediaRef::Id(CollectionId::from_raw(raw)),
Either::Right(slug) => MediaRef::Slug(slug),
};
for await event in collection::Scanner::scan_collection(path, parent, id) {
yield event.map(|e| e.into());
}
}
MediaType::Movie => {
for await event in
movie::Scanner::scan_movie(path, parent, MovieId::from_raw(id))
{
let id = match media_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());
}
}
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());
}
}
+83
View File
@@ -3,6 +3,9 @@
//! The most common scanner to use is [generic::Scanner] which will
//! automatically detect and use the appropriate scanner.
use flix_model::id::{CollectionId, MovieId, ShowId};
use flix_model::numbers::{EpisodeNumbers, SeasonNumber};
pub mod library;
pub mod generic;
@@ -14,3 +17,83 @@ pub mod movie;
pub mod episode;
pub mod season;
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::macros::{is_image_extension, is_media_extension};
use crate::scanner::{MediaRef, MovieScan};
/// An movie item
pub type Item = crate::Item<Scanner>;
@@ -19,24 +20,15 @@ pub type Item = crate::Item<Scanner>;
/// The scanner for movies
pub enum Scanner {
/// A scanned movie
Movie {
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the movie
id: MovieId,
/// The file name of the media file
media_file_name: String,
/// The file name of the poster file
poster_file_name: Option<String>,
},
Movie(MovieScan),
}
impl Scanner {
/// Scan a folder for a movie
pub fn scan_movie(
path: &Path,
parent: Option<CollectionId>,
id: MovieId,
parent_ref: Option<MediaRef<CollectionId>>,
id_ref: MediaRef<MovieId>,
) -> impl Stream<Item = Item> {
stream!({
let dirs = match fs::read_dir(path).await {
@@ -131,12 +123,12 @@ impl Scanner {
yield Item {
path: path.to_owned(),
event: Ok(Self::Movie {
parent,
id,
event: Ok(Self::Movie(MovieScan {
parent_ref,
id_ref,
media_file_name,
poster_file_name,
}),
})),
};
})
}
+10 -40
View File
@@ -13,53 +13,23 @@ use tokio_stream::wrappers::ReadDirStream;
use crate::Error;
use crate::macros::is_image_extension;
use crate::scanner::episode;
use crate::scanner::{EpisodeScan, MediaRef, SeasonScan, episode};
/// A season item
pub type Item = crate::Item<Scanner>;
/// The scanner for seasons
pub enum Scanner {
/// A scanned season
Season(SeasonScan),
/// A scanned episode
Season {
/// 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>,
},
Episode(EpisodeScan),
}
impl From<episode::Scanner> for Scanner {
fn from(value: episode::Scanner) -> Self {
match value {
episode::Scanner::Episode {
show,
season,
episode,
media_file_name,
poster_file_name,
} => Self::Episode {
show,
season,
episode,
media_file_name,
poster_file_name,
},
episode::Scanner::Episode(e) => Self::Episode(e),
}
}
}
@@ -68,7 +38,7 @@ impl Scanner {
/// Scan a folder for a season and its episodes
pub fn scan_season(
path: &Path,
show: ShowId,
show_ref: MediaRef<ShowId>,
season: SeasonNumber,
) -> impl Stream<Item = Item> {
stream!({
@@ -140,11 +110,11 @@ impl Scanner {
yield Item {
path: path.to_owned(),
event: Ok(Self::Season {
show,
event: Ok(Self::Season(SeasonScan {
show_ref: show_ref.clone(),
season,
poster_file_name,
}),
})),
};
for episode_dir in episode_dirs_to_scan {
@@ -207,7 +177,7 @@ impl Scanner {
for await event in episode::Scanner::scan_episode(
&episode_dir,
show,
show_ref.clone(),
season_number,
episode_numbers,
) {
+17 -60
View File
@@ -4,7 +4,7 @@ use std::ffi::OsStr;
use std::path::Path;
use flix_model::id::{CollectionId, ShowId};
use flix_model::numbers::{EpisodeNumbers, SeasonNumber};
use flix_model::numbers::SeasonNumber;
use async_stream::stream;
use tokio::fs;
@@ -13,7 +13,7 @@ use tokio_stream::wrappers::ReadDirStream;
use crate::Error;
use crate::macros::is_image_extension;
use crate::scanner::season;
use crate::scanner::{EpisodeScan, MediaRef, SeasonScan, ShowScan, season};
/// A show item
pub type Item = crate::Item<Scanner>;
@@ -21,63 +21,18 @@ pub type Item = crate::Item<Scanner>;
/// The scanner for shows
pub enum Scanner {
/// A scanned show
Show {
/// The ID of the parent collection (if any)
parent: Option<CollectionId>,
/// The ID of the show
id: ShowId,
/// The file name of the poster file
poster_file_name: Option<String>,
},
Show(ShowScan),
/// A scanned season
Season(SeasonScan),
/// A scanned episode
Season {
/// 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>,
},
Episode(EpisodeScan),
}
impl From<season::Scanner> for Scanner {
fn from(value: season::Scanner) -> Self {
match value {
season::Scanner::Season {
show,
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,
},
season::Scanner::Season(s) => Self::Season(s),
season::Scanner::Episode(e) => Self::Episode(e),
}
}
}
@@ -86,8 +41,8 @@ impl Scanner {
/// Scan a folder for a show and its seasons/episodes
pub fn scan_show(
path: &Path,
parent: Option<CollectionId>,
id: ShowId,
parent_ref: Option<MediaRef<CollectionId>>,
id_ref: MediaRef<ShowId>,
) -> impl Stream<Item = Item> {
stream!({
let dirs = match fs::read_dir(path).await {
@@ -158,11 +113,11 @@ impl Scanner {
yield Item {
path: path.to_owned(),
event: Ok(Self::Show {
parent,
id,
event: Ok(Self::Show(ShowScan {
parent_ref,
id_ref: id_ref.clone(),
poster_file_name,
}),
})),
};
for season_dir in season_dirs_to_scan {
@@ -185,7 +140,9 @@ impl Scanner {
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());
}
}
+8 -5
View File
@@ -1,13 +1,15 @@
[package]
name = "flix-model"
version = "0.0.16"
edition.workspace = true
rust-version.workspace = true
version = "0.0.19"
license-file.workspace = true
description = "Core types for flix data"
repository = "https://github.com/QuantumShade/flix"
license-file.workspace = true
categories = []
edition.workspace = true
rust-version.workspace = true
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
@@ -15,9 +17,10 @@ rustdoc-args = ["--cfg", "docsrs"]
[dependencies]
itertools = { workspace = true }
seamantic = { workspace = true }
serde = { workspace = true, features = ["derive", "std"], optional = true }
thiserror = { workspace = true }
serde = { workspace = true, features = ["derive", "std"], optional = true }
[features]
default = []
serde = ["dep:serde"]
+4 -1
View File
@@ -12,7 +12,9 @@ fn split_normalized_words(input: &str) -> impl Iterator<Item = String> {
panic!("Input is not ASCII: {input}");
}
input.split_ascii_whitespace().map(|s| {
input
.split_ascii_whitespace()
.map(|s| {
let chars = s
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
@@ -28,6 +30,7 @@ fn split_normalized_words(input: &str) -> impl Iterator<Item = String> {
chars.collect()
}
})
.filter(|part: &String| !part.is_empty())
}
fn split_leading_article<I: Iterator<Item = String>>(iter: I) -> (Option<String>, Peekable<I>) {
+12 -6
View File
@@ -1,29 +1,35 @@
[package]
name = "flix-tmdb"
version = "0.0.16"
edition.workspace = true
rust-version.workspace = true
version = "0.0.19"
license-file.workspace = true
description = "Clients and models for fetching data from TMDB"
repository = "https://github.com/QuantumShade/flix"
license-file.workspace = true
categories = []
edition.workspace = true
rust-version.workspace = true
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[dependencies]
bytes = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
flix-model = { workspace = true, features = ["serde"] }
governor = { workspace = true, features = ["jitter", "std"] }
nonzero_ext = { workspace = true }
reqwest = { workspace = true, features = ["json", "query", "rustls"] }
sea-orm = { workspace = true, optional = true }
redb = { workspace = true }
reqwest = { workspace = true, features = ["query", "rustls"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
url = { workspace = true }
url-macro = { workspace = true }
sea-orm = { workspace = true, optional = true }
[dev-dependencies]
serde_test = { workspace = true }
+14 -23
View File
@@ -1,25 +1,30 @@
//! Collections API
use core::time::Duration;
use std::rc::Rc;
use std::sync::RwLock;
use governor::Jitter;
use crate::Config;
use crate::api::exec_request;
use crate::model::Collection;
use crate::model::id::CollectionId;
use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request};
/// TMDB Collections API client
pub struct Client {
config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Rc<Config>) -> Self {
Self { config }
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
Self {
config,
cache,
policy,
}
}
}
@@ -30,25 +35,11 @@ impl Client {
id: impl Into<CollectionId>,
language: Option<&str>,
) -> Result<Collection, Error> {
self.config
.limiter
.until_ready_with_jitter(Jitter::new(
Duration::from_millis(0),
Duration::from_millis(50),
))
.await;
Ok(self
.config
.client
.execute(make_request(
let request = make_request(
&self.config,
&format!("/3/collection/{}", id.into().into_raw()),
language,
)?)
.await?
.error_for_status()?
.json()
.await?)
)?;
exec_request(&self.config, &*self.cache, &self.policy, request).await
}
}
+14 -23
View File
@@ -1,27 +1,32 @@
//! Episodes API
use core::time::Duration;
use std::rc::Rc;
use std::sync::RwLock;
use flix_model::numbers::{EpisodeNumber, SeasonNumber};
use governor::Jitter;
use crate::Config;
use crate::api::exec_request;
use crate::model::Episode;
use crate::model::id::ShowId;
use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request};
/// TMDB Episodes API client
pub struct Client {
config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Rc<Config>) -> Self {
Self { config }
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
Self {
config,
cache,
policy,
}
}
}
@@ -34,18 +39,7 @@ impl Client {
episode: impl Into<EpisodeNumber>,
language: Option<&str>,
) -> Result<Episode, Error> {
self.config
.limiter
.until_ready_with_jitter(Jitter::new(
Duration::from_millis(0),
Duration::from_millis(50),
))
.await;
Ok(self
.config
.client
.execute(make_request(
let request = make_request(
&self.config,
&format!(
"/3/tv/{}/season/{}/episode/{}",
@@ -54,10 +48,7 @@ impl Client {
episode.into()
),
language,
)?)
.await?
.error_for_status()?
.json()
.await?)
)?;
exec_request(&self.config, &*self.cache, &self.policy, request).await
}
}
+65 -1
View File
@@ -1,9 +1,15 @@
//! TMDB API clients
use core::ops::Deref;
use core::time::Duration;
use std::sync::RwLock;
use governor::Jitter;
use reqwest::Request;
use reqwest::header;
use serde::de::DeserializeOwned;
use crate::Config;
use crate::{Cache, CachePolicy, Config};
pub mod collections;
pub mod episodes;
@@ -20,6 +26,9 @@ pub enum Error {
/// Reqwest error wrapper
#[error("reqwest error: {0}")]
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> {
@@ -38,3 +47,58 @@ fn make_request(config: &Config, path: &str, language: Option<&str>) -> Result<R
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)?)
}
+14 -23
View File
@@ -1,25 +1,30 @@
//! Movies API
use core::time::Duration;
use std::rc::Rc;
use std::sync::RwLock;
use governor::Jitter;
use crate::Config;
use crate::api::exec_request;
use crate::model::Movie;
use crate::model::id::MovieId;
use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request};
/// TMDB Movies API client
pub struct Client {
config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Rc<Config>) -> Self {
Self { config }
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
Self {
config,
cache,
policy,
}
}
}
@@ -30,25 +35,11 @@ impl Client {
id: impl Into<MovieId>,
language: Option<&str>,
) -> Result<Movie, Error> {
self.config
.limiter
.until_ready_with_jitter(Jitter::new(
Duration::from_millis(0),
Duration::from_millis(50),
))
.await;
Ok(self
.config
.client
.execute(make_request(
let request = make_request(
&self.config,
&format!("/3/movie/{}", id.into().into_raw()),
language,
)?)
.await?
.error_for_status()?
.json()
.await?)
)?;
exec_request(&self.config, &*self.cache, &self.policy, request).await
}
}
+14 -23
View File
@@ -1,27 +1,32 @@
//! Seasons API
use core::time::Duration;
use std::rc::Rc;
use std::sync::RwLock;
use flix_model::numbers::SeasonNumber;
use governor::Jitter;
use crate::Config;
use crate::api::exec_request;
use crate::model::Season;
use crate::model::id::ShowId;
use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request};
/// TMDB Seasons API client
pub struct Client {
config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Rc<Config>) -> Self {
Self { config }
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
Self {
config,
cache,
policy,
}
}
}
@@ -33,25 +38,11 @@ impl Client {
season: impl Into<SeasonNumber>,
language: Option<&str>,
) -> Result<Season, Error> {
self.config
.limiter
.until_ready_with_jitter(Jitter::new(
Duration::from_millis(0),
Duration::from_millis(50),
))
.await;
Ok(self
.config
.client
.execute(make_request(
let request = make_request(
&self.config,
&format!("/3/tv/{}/season/{}", id.into().into_raw(), season.into()),
language,
)?)
.await?
.error_for_status()?
.json()
.await?)
)?;
exec_request(&self.config, &*self.cache, &self.policy, request).await
}
}
+14 -23
View File
@@ -1,25 +1,30 @@
//! Shows API
use core::time::Duration;
use std::rc::Rc;
use std::sync::RwLock;
use governor::Jitter;
use crate::Config;
use crate::api::exec_request;
use crate::model::Show;
use crate::model::id::ShowId;
use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request};
/// TMDB Shows API client
pub struct Client {
config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Rc<Config>) -> Self {
Self { config }
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
Self {
config,
cache,
policy,
}
}
}
@@ -30,25 +35,11 @@ impl Client {
id: impl Into<ShowId>,
language: Option<&str>,
) -> Result<Show, Error> {
self.config
.limiter
.until_ready_with_jitter(Jitter::new(
Duration::from_millis(0),
Duration::from_millis(50),
))
.await;
Ok(self
.config
.client
.execute(make_request(
let request = make_request(
&self.config,
&format!("/3/tv/{}", id.into().into_raw()),
language,
)?)
.await?
.error_for_status()?
.json()
.await?)
)?;
exec_request(&self.config, &*self.cache, &self.policy, request).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::sync::RwLock;
use crate::{Config, api};
use crate::{Cache, CachePolicy, Config, api};
/// The primary client that references all other clients
pub struct Client {
@@ -9,23 +10,42 @@ pub struct Client {
shows: api::shows::Client,
seasons: api::seasons::Client,
episodes: api::episodes::Client,
cache_policy: Rc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client from a default configuration using the bearer token
pub fn new(bearer_token: String) -> Self {
Self::new_with_config(Config::new(bearer_token))
/// Create a new client with the given configuration
pub fn new(config: Config, cache: Rc<dyn Cache>, cache_policy: CachePolicy) -> Self {
let config = Rc::new(config);
let cache_policy = Rc::new(RwLock::new(cache_policy));
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
pub fn new_with_config(config: Config) -> Self {
let config = Rc::new(config);
Self {
collections: api::collections::Client::new(config.clone()),
movies: api::movies::Client::new(config.clone()),
shows: api::shows::Client::new(config.clone()),
seasons: api::seasons::Client::new(config.clone()),
episodes: api::episodes::Client::new(config.clone()),
/// Modify the [CachePolicy]
pub fn set_cache_policy(&self, new_policy: CachePolicy) {
match self.cache_policy.write() {
Ok(mut policy) => *policy = new_policy,
Err(mut poison) => {
**poison.get_mut() = new_policy;
self.cache_policy.clear_poison();
}
}
}
}
+3
View File
@@ -5,6 +5,9 @@
pub mod api;
pub mod model;
mod cache;
pub use cache::{Cache, CachePolicy, RedbCache};
mod client;
pub use client::Client;
+6 -2
View File
@@ -3,8 +3,9 @@ use core::time::Duration;
use flix_model::numbers::EpisodeNumber;
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
#[derive(Debug, Clone, serde::Deserialize)]
@@ -18,7 +19,10 @@ pub struct Episode {
pub overview: String,
/// The episode's air date
pub air_date: NaiveDate,
/// The movie's runtime
/// The episode's runtime
#[serde(deserialize_with = "duration_from_minutes")]
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
use core::str::FromStr;
use core::time::Duration;
use serde::{Deserialize, Deserializer};
use url::Url;
pub mod id;
@@ -22,6 +24,24 @@ fn duration_from_minutes<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let minutes = u64::deserialize(deserializer)?;
let minutes = u64::deserialize(deserializer).unwrap_or(0);
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]
indent-style = "tab"
indent-width = 4
# Required for rust <1.94
[[schemas]]
toml-version = "v1.0.0"
path = "tombi://www.schemastore.org/cargo.json"
include = ["Cargo.toml"]