Update dependencies and lints

This commit is contained in:
2026-09-07 22:31:04 -07:00
parent b518d762f1
commit 9c288d051c
79 changed files with 3473 additions and 2923 deletions
+4
View File
@@ -0,0 +1,4 @@
allow-expect-in-tests = true
check-private-items = true
allowed-duplicate-crates = []
+21 -9
View File
@@ -1,11 +1,23 @@
# OS Files
*~
._*
.DS_Store
*
!**/
# Rust
/target
!.zed/settings.json
!.gitignore
# Flix
flix.db
flix.redb
!/CONTRIBUTING.md
!/LICENSE.md
!/README.md
!/.clippy.toml
!/.rustfmt.toml
!/.tombi.toml
!/Cargo.toml
!/Cargo.lock
!/justfile
!/rust-toolchain.toml
!/crates/**/*.rs
!/crates/**/Cargo.toml
!/crates/**/README.md
!/lints/*.toml
-1
View File
@@ -1,2 +1 @@
hard_tabs = true
wrap_comments = true
+10
View File
@@ -0,0 +1,10 @@
[format.rules]
indent-style = "tab"
indent-width = 4
line-width = 120
# Disable TOML 1.1 features from tombi as long as we support rust <1.94
[[schemas]]
toml-version = "v1.0.0"
path = "tombi://www.schemastore.org/cargo.json"
include = ["Cargo.toml"]
+7 -6
View File
@@ -1,11 +1,12 @@
{
"languages": {
"TOML": {
"format_on_save": "on",
"formatter": { "language_server": { "name": "tombi" } },
},
},
"lsp": {
"just-lsp": {
"initialization_options": {
"formatting": {
"indentation": "\t",
},
},
},
"rust-analyzer": {
"initialization_options": {
"imports": {
Generated
+608 -991
View File
File diff suppressed because it is too large Load Diff
+555 -59
View File
@@ -1,74 +1,570 @@
[workspace]
resolver = "2"
resolver = "3"
members = ["crates/*"]
[workspace.package]
license-file = "LICENSE.md"
edition = "2024"
rust-version = "1.89.0"
rust-version = "1.94"
license-file = "LICENSE.md"
publish = false
[workspace.dependencies]
flix = { path = "crates/flix", version = "=0.0.20", default-features = false }
flix-cli = { path = "crates/cli", version = "=0.0.20", default-features = false }
flix-db = { path = "crates/db", version = "=0.0.20", default-features = false }
flix-fs = { path = "crates/fs", version = "=0.0.20", default-features = false }
flix-model = { path = "crates/model", version = "=0.0.20", default-features = false }
flix-mux = { path = "crates/cli-mux", version = "=0.0.20", default-features = false }
flix-tmdb = { path = "crates/tmdb", version = "=0.0.20", default-features = false }
flix = { path = "crates/flix", version = "=0.1.0", default-features = false }
flix-cli = { path = "crates/cli", version = "=0.1.0", default-features = false }
flix-db = { path = "crates/db", version = "=0.1.0", default-features = false }
flix-fs = { path = "crates/fs", version = "=0.1.0", default-features = false }
flix-model = { path = "crates/model", version = "=0.1.0", default-features = false }
flix-mux = { path = "crates/cli-mux", version = "=0.1.0", default-features = false }
flix-tmdb = { path = "crates/tmdb", version = "=0.1.0", default-features = false }
seamantic = { version = "^0.0.14", default-features = false }
sea-orm = { version = "=2.0.0-rc.38", default-features = false }
sea-orm-migration = { version = "=2.0.0-rc.38", default-features = false }
anyhow = { version = "^1", default-features = false }
async-stream = { version = "^0.3", default-features = false }
bytes = { version = "^1", default-features = false }
chrono = { version = "^0.4", default-features = false }
clap = { version = "^4", default-features = false }
console = { version = "^0.16", default-features = false }
dialoguer = { version = "^0.12", default-features = false }
either = { version = "^1", default-features = false }
futures = { version = "^0.3", default-features = false }
governor = { version = "^0.10", default-features = false }
indicatif = { version = "^0.18", default-features = false }
itertools = { version = "^0.14", default-features = false }
nonzero_ext = { version = "^0.3", default-features = false }
redb = { version = "^4", default-features = false }
regex = { version = "^1", default-features = false }
reqwest = { version = "^0.13", default-features = false }
serde = { version = "^1", default-features = false }
serde_json = { version = "^1", default-features = false }
serde_test = { version = "^1", default-features = false }
thiserror = { version = "^2", default-features = false }
tokio = { version = "^1", default-features = false }
tokio-stream = { version = "^0.1", default-features = false }
toml = { version = "^1", default-features = false }
tracing = { version = "^0.1", default-features = false }
tracing-subscriber = { version = "^0.3", default-features = false }
url = { version = "^2", default-features = false }
url-macro = { version = "^0.2", default-features = false }
walkdir = { version = "^2", default-features = false }
anyhow = { version = "^1.0.87", default-features = false }
async-stream = { version = "^0.3.5", default-features = false }
bytes = { version = "^1.7.1", default-features = false }
chrono = { version = "^0.4.40", default-features = false }
clap = { version = "^4.3.2", default-features = false }
console = { version = "^0.16.0", default-features = false }
either = { version = "^1.6.1", default-features = false }
governor = { version = "^0.10.0", default-features = false }
indicatif = { version = "^0.18.0", default-features = false }
itertools = { version = "^0.15.0", default-features = false }
nonzero_ext = { version = "^0.3.0", default-features = false }
redb = { version = "^4.0.0", default-features = false }
regex = { version = "^1.11.2", default-features = false }
reqwest = { version = "^0.13.0", default-features = false }
sea-orm = { version = "^2.0.1", default-features = false }
sea-orm-migration = { version = "^2.0.0", default-features = false }
seamantic = { version = "^1.0.1", default-features = false }
serde = { version = "^1.0.225", default-features = false }
serde_json = { version = "^1.0.142", default-features = false }
serde_test = { version = "^1.0.0", default-features = false }
tempfile = { version = "^3.0.0", default-features = false }
thiserror = { version = "^2.0.18", default-features = false }
tokio = { version = "^1.48.0", default-features = false }
tokio-stream = { version = "^0.1.8", default-features = false }
toml = { version = "^1.0.0", default-features = false }
tracing = { version = "^0.1.37", default-features = false }
tracing-subscriber = { version = "^0.3.17", default-features = false }
url = { version = "^2.4.0", default-features = false }
url-macro = { version = "^0.2.0", default-features = false }
walkdir = { version = "^2.0.0", default-features = false }
[workspace.lints.clippy]
arithmetic_side_effects = "forbid"
as_conversions = "forbid"
checked_conversions = "forbid"
default_union_representation = "forbid"
expect_used = "forbid"
indexing_slicing = "forbid"
integer_division = "forbid"
integer_division_remainder_used = "forbid"
transmute_undefined_repr = "forbid"
unchecked_time_subtraction = "forbid"
unwrap_used = "forbid"
cargo = { level = "warn", priority = -1 }
complexity = { level = "warn", priority = -1 }
correctness = { level = "deny", priority = -1 }
perf = { level = "warn", priority = -1 }
style = { level = "warn", priority = -1 }
suspicious = { level = "warn", priority = -1 }
absolute-paths = "allow"
alloc-instead-of-core = "warn"
allow-attributes = "warn"
allow-attributes-without-reason = "deny"
arbitrary-source-item-ordering = "allow"
arithmetic-side-effects = "deny"
as-conversions = "deny"
as-pointer-underscore = "deny"
as-ptr-cast-mut = "deny"
as-underscore = "deny"
assertions-on-result-states = "warn"
assigning-clones = "warn"
big-endian-bytes = "allow"
bool-to-int-with-if = "warn"
borrow-as-ptr = "warn"
branches-sharing-code = "warn"
case-sensitive-file-extension-comparisons = "warn"
cast-lossless = "deny"
cast-possible-truncation = "deny"
cast-possible-wrap = "deny"
cast-precision-loss = "deny"
cast-ptr-alignment = "deny"
cast-sign-loss = "deny"
cfg-not-test = "deny"
checked-conversions = "deny"
clear-with-drain = "warn"
clone-on-ref-ptr = "warn"
cloned-instead-of-copied = "warn"
coerce-container-to-any = "warn"
cognitive-complexity = "allow"
collapsible-else-if = "warn"
collection-is-never-read = "warn"
comparison-chain = "warn"
copy-iterator = "warn"
create-dir = "warn"
dbg-macro = "warn"
debug-assert-with-mut-call = "deny"
decimal-bitwise-operands = "warn"
decimal-literal-representation = "warn"
default-numeric-fallback = "warn"
default-trait-access = "warn"
default-union-representation = "allow"
deref-by-slicing = "warn"
derive-partial-eq-without-eq = "warn"
disallowed-script-idents = "deny"
doc-broken-link = "warn"
doc-comment-double-space-linebreaks = "warn"
doc-include-without-cfg = "warn"
doc-link-code = "warn"
doc-link-with-quotes = "warn"
doc-markdown = "warn"
doc-paragraphs-missing-punctuation = "warn"
duration-suboptimal-units = "warn"
elidable-lifetime-names = "warn"
else-if-without-else = "warn"
empty-drop = "warn"
empty-enum-variants-with-brackets = "warn"
empty-enums = "warn"
empty-structs-with-brackets = "warn"
enum-glob-use = "warn"
equatable-if-let = "warn"
error-impl-error = "warn"
exhaustive-enums = "warn"
exhaustive-structs = "warn"
exit = "warn"
expect-used = "warn"
expl-impl-clone-on-copy = "warn"
explicit-deref-methods = "warn"
explicit-into-iter-loop = "warn"
explicit-iter-loop = "warn"
fallible-impl-from = "warn"
field-scoped-visibility-modifiers = "warn"
filetype-is-file = "warn"
filter-map-next = "warn"
flat-map-option = "warn"
float-arithmetic = "warn"
float-cmp = "warn"
float-cmp-const = "warn"
fn-params-excessive-bools = "allow"
fn-to-numeric-cast-any = "warn"
format-collect = "warn"
format-push-string = "warn"
future-not-send = "warn"
get-unwrap = "warn"
host-endian-bytes = "allow"
if-not-else = "warn"
if-then-some-else-none = "warn"
ignore-without-reason = "warn"
ignored-unit-patterns = "warn"
impl-trait-in-params = "warn"
implicit-clone = "warn"
implicit-hasher = "warn"
implicit-return = "allow"
imprecise-flops = "warn"
inconsistent-struct-constructor = "warn"
index-refutable-slice = "deny"
indexing-slicing = "deny"
inefficient-to-string = "warn"
infinite-loop = "warn"
inline-always = "warn"
inline-asm-x86-att-syntax = "warn"
inline-asm-x86-intel-syntax = "warn"
inline-modules = "allow"
inline-trait-bounds = "allow"
integer-division = "deny"
integer-division-remainder-used = "deny"
into-iter-without-iter = "warn"
invalid-upcast-comparisons = "warn"
ip-constant = "warn"
items-after-statements = "warn"
iter-filter-is-ok = "warn"
iter-filter-is-some = "warn"
iter-not-returning-iterator = "warn"
iter-on-empty-collections = "warn"
iter-on-single-items = "warn"
iter-over-hash-type = "warn"
iter-with-drain = "warn"
iter-without-into-iter = "warn"
large-digit-groups = "warn"
large-futures = "warn"
large-include-file = "warn"
large-stack-arrays = "warn"
large-stack-frames = "warn"
large-types-passed-by-value = "warn"
let-underscore-must-use = "warn"
let-underscore-untyped = "warn"
linkedlist = "warn"
literal-string-with-formatting-args = "warn"
little-endian-bytes = "allow"
lossy-float-literal = "warn"
macro-use-imports = "warn"
manual-assert = "warn"
manual-assert-eq = "warn"
manual-ilog2 = "warn"
manual-instant-elapsed = "warn"
manual-is-power-of-two = "warn"
manual-is-variant-and = "warn"
manual-let-else = "warn"
manual-midpoint = "warn"
manual-string-new = "warn"
many-single-char-names = "warn"
map-err-ignore = "warn"
map-unwrap-or = "warn"
map-with-unused-argument-over-ranges = "warn"
match-bool = "warn"
match-same-arms = "warn"
match-wild-err-arm = "warn"
match-wildcard-for-single-variants = "warn"
maybe-infinite-iter = "warn"
mem-forget = "warn"
min-ident-chars = "allow"
mismatching-type-param-order = "warn"
missing-assert-message = "warn"
missing-asserts-for-indexing = "warn"
missing-const-for-fn = "warn"
missing-docs-in-private-items = "warn"
missing-errors-doc = "warn"
missing-fields-in-debug = "warn"
missing-inline-in-public-items = "warn"
missing-panics-doc = "warn"
missing-trait-methods = "warn"
mixed-read-write-in-expression = "warn"
mod-module-files = "allow"
module-name-repetitions = "warn"
modulo-arithmetic = "warn"
multiple-crate-versions = "allow"
multiple-inherent-impl = "warn"
multiple-unsafe-ops-per-block = "warn"
must-use-candidate = "warn"
mut-mut = "warn"
mutex-atomic = "warn"
mutex-integer = "warn"
naive-bytecount = "warn"
needless-bitwise-bool = "warn"
needless-collect = "warn"
needless-continue = "warn"
needless-for-each = "warn"
needless-pass-by-ref-mut = "warn"
needless-pass-by-value = "warn"
needless-raw-string-hashes = "warn"
needless-raw-strings = "warn"
needless-type-cast = "warn"
no-effect-underscore-binding = "warn"
no-mangle-with-rust-abi = "warn"
non-ascii-literal = "allow"
non-send-fields-in-send-ty = "warn"
non-std-lazy-statics = "warn"
non-zero-suggestions = "warn"
nonminimal-bool = "warn"
nonstandard-macro-braces = "warn"
option-as-ref-cloned = "warn"
option-if-let-else = "warn"
option-option = "warn"
or-fun-call = "warn"
overly-complex-bool-expr = "allow"
panic = "warn"
panic-in-result-fn = "warn"
partial-pub-fields = "warn"
path-buf-push-overwrite = "warn"
pathbuf-init-then-push = "warn"
pattern-type-mismatch = "allow"
pointer-format = "warn"
precedence-bits = "warn"
print-stderr = "warn"
print-stdout = "warn"
ptr-as-ptr = "warn"
ptr-cast-constness = "warn"
ptr-offset-by-literal = "warn"
pub-underscore-fields = "warn"
pub-use = "allow"
pub-with-shorthand = "allow"
pub-without-shorthand = "warn"
question-mark-used = "allow"
range-minus-one = "warn"
range-plus-one = "warn"
rc-buffer = "warn"
rc-mutex = "warn"
read-zero-byte-vec = "warn"
redundant-clone = "warn"
redundant-closure-for-method-calls = "warn"
redundant-else = "warn"
redundant-pub-crate = "allow"
redundant-test-prefix = "warn"
redundant-type-annotations = "warn"
ref-as-ptr = "warn"
ref-binding-to-reference = "warn"
ref-option = "warn"
ref-option-ref = "warn"
ref-patterns = "warn"
renamed-function-params = "warn"
rest-pat-in-fully-bound-structs = "warn"
return-and-then = "warn"
return-self-not-must-use = "warn"
same-functions-in-if-condition = "warn"
same-length-and-capacity = "warn"
same-name-method = "warn"
search-is-some = "warn"
self-named-module-files = "warn"
self-only-used-in-recursion = "warn"
semicolon-if-nothing-returned = "warn"
semicolon-inside-block = "warn"
semicolon-outside-block = "allow"
separated-literal-suffix = "allow"
set-contains-or-insert = "warn"
shadow-reuse = "allow"
shadow-same = "allow"
shadow-unrelated = "allow"
should-panic-without-expect = "warn"
significant-drop-in-scrutinee = "warn"
significant-drop-tightening = "warn"
similar-names = "allow"
single-call-fn = "allow"
single-char-lifetime-names = "allow"
single-char-pattern = "allow"
single-match-else = "warn"
single-option-map = "warn"
stable-sort-primitive = "warn"
std-instead-of-alloc = "warn"
std-instead-of-core = "warn"
str-split-at-newline = "warn"
str-to-string = "warn"
string-add = "warn"
string-add-assign = "warn"
string-lit-as-bytes = "warn"
string-lit-chars-any = "warn"
string-slice = "warn"
struct-excessive-bools = "allow"
struct-field-names = "warn"
suboptimal-flops = "warn"
suspicious-operation-groupings = "warn"
suspicious-xor-used-as-pow = "warn"
tests-outside-test-module = "warn"
todo = "warn"
too-long-first-doc-paragraph = "allow"
too-many-lines = "allow"
trailing-empty-array = "warn"
trait-duplication-in-bounds = "warn"
transmute-ptr-to-ptr = "warn"
transmute-undefined-repr = "warn"
trivial-regex = "warn"
trivially-copy-pass-by-ref = "warn"
try-err = "warn"
tuple-array-conversions = "warn"
type-repetition-in-bounds = "warn"
unchecked-time-subtraction = "deny"
undocumented-unsafe-blocks = "deny"
unicode-not-nfc = "warn"
unimplemented = "warn"
uninhabited-references = "warn"
uninlined-format-args = "warn"
unnecessary-box-returns = "warn"
unnecessary-debug-formatting = "warn"
unnecessary-join = "warn"
unnecessary-literal-bound = "warn"
unnecessary-safety-comment = "warn"
unnecessary-safety-doc = "warn"
unnecessary-self-imports = "warn"
unnecessary-semicolon = "warn"
unnecessary-struct-initialization = "warn"
unnecessary-trailing-comma = "warn"
unnecessary-wraps = "warn"
unneeded-field-pattern = "warn"
unnested-or-patterns = "warn"
unreachable = "warn"
unreadable-literal = "warn"
unsafe-derive-deserialize = "warn"
unseparated-literal-suffix = "warn"
unused-async = "warn"
unused-async-trait-impl = "warn"
unused-peekable = "warn"
unused-result-ok = "warn"
unused-rounding = "warn"
unused-self = "warn"
unused-trait-names = "warn"
unwrap-in-result = "warn"
unwrap-used = "warn"
use-debug = "warn"
use-self = "warn"
used-underscore-binding = "warn"
used-underscore-items = "warn"
useless-let-if-seq = "warn"
verbose-bit-mask = "warn"
verbose-file-reads = "warn"
volatile-composites = "warn"
while-float = "warn"
wildcard-enum-match-arm = "warn"
wildcard-imports = "warn"
with-capacity-zero = "warn"
zero-sized-map-values = "warn"
[workspace.lints.rust]
arithmetic_overflow = "forbid"
missing_docs = "forbid"
unsafe_code = "forbid"
unused_doc_comments = "forbid"
deprecated-safe = "forbid"
future-incompatible = "deny"
keyword-idents = "deny"
let-underscore = "warn"
nonstandard-style = "warn"
refining-impl-trait = "warn"
rust-2018-compatibility = "deny"
rust-2018-idioms = "deny"
rust-2021-compatibility = "deny"
rust-2024-compatibility = "deny"
unknown-or-malformed-diagnostic-attributes = "deny"
unused = { level = "warn", priority = -1 }
ambiguous-glob-reexports = "warn"
ambiguous-negative-literals = "deny"
ambiguous-wide-pointer-comparisons = "warn"
arithmetic-overflow = "deny"
asm-sub-register = "warn"
async-fn-in-trait = "warn"
bad-asm-style = "warn"
binary-asm-labels = "deny"
bindings-with-variant-name = "deny"
break-with-label-and-loop = "warn"
c-void-returns = "warn"
clashing-extern-declarations = "warn"
closure-returning-async-block = "warn"
confusable-idents = "warn"
const-item-interior-mutations = "warn"
const-item-mutation = "warn"
dangerous-implicit-autorefs = "deny"
dangling-pointers-from-locals = "warn"
dangling-pointers-from-temporaries = "warn"
dead-code-pub-in-binary = "warn"
deprecated = "warn"
deprecated-in-future = "allow"
deprecated-where-clause-location = "warn"
deref-into-dyn-supertrait = "warn"
deref-nullptr = "deny"
double-negations = "warn"
drop-bounds = "warn"
dropping-copy-types = "warn"
dropping-references = "warn"
duplicate-features = "deny"
duplicate-macro-attributes = "warn"
dyn-drop = "warn"
enum-intrinsics-non-enums = "deny"
explicit-builtin-cfgs-in-flags = "deny"
exported-private-dependencies = "warn"
ffi-unwind-calls = "warn"
for-loops-over-fallibles = "warn"
forgetting-copy-types = "warn"
forgetting-references = "warn"
function-casts-as-integer = "warn"
function-item-references = "warn"
hidden-glob-reexports = "warn"
impl-trait-redundant-captures = "warn"
improper-ctypes = "warn"
improper-ctypes-definitions = "warn"
improper-gpu-kernel-arg = "warn"
incomplete-features = "warn"
incomplete-include = "deny"
ineffective-unstable-trait-impl = "deny"
inline-no-sanitize = "warn"
integer-to-ptr-transmutes = "warn"
internal-features = "warn"
invalid-atomic-ordering = "deny"
invalid-doc-attributes = "warn"
invalid-from-utf8 = "warn"
invalid-from-utf8-unchecked = "deny"
invalid-nan-comparisons = "warn"
invalid-null-arguments = "deny"
invalid-reference-casting = "deny"
invalid-runtime-symbol-definitions = "deny"
invalid-value = "warn"
irrefutable-let-patterns = "warn"
large-assignments = "warn"
linker-info = "allow"
linker-messages = "warn"
long-running-const-eval = "deny"
macro-use-extern-crate = "warn"
meta-variable-misuse = "warn"
mismatched-lifetime-syntaxes = "warn"
missing-abi = "warn"
missing-copy-implementations = "warn"
missing-debug-implementations = "warn"
missing-docs = "warn"
missing-gpu-kernel-export-name = "warn"
mixed-script-confusables = "warn"
mutable-transmutes = "deny"
named-arguments-used-positionally = "warn"
named-asm-labels = "deny"
no-mangle-const-items = "deny"
no-mangle-generic-items = "warn"
non-ascii-idents = "deny"
non-contiguous-range-endpoints = "warn"
non-local-definitions = "warn"
non-shorthand-field-patterns = "warn"
noop-method-call = "warn"
opaque-hidden-inferred-bound = "warn"
overflowing-literals = "deny"
overlapping-range-endpoints = "warn"
private-bounds = "warn"
private-interfaces = "warn"
ptr-to-integer-transmute-in-consts = "warn"
redundant-imports = "warn"
redundant-lifetimes = "warn"
renamed-and-removed-lints = "warn"
rtsan-nonblocking-async = "warn"
single-use-lifetimes = "warn"
special-module-name = "warn"
stable-features = "warn"
suspicious-double-ref-op = "warn"
suspicious-runtime-symbol-definitions = "warn"
text-direction-codepoint-in-comment = "deny"
text-direction-codepoint-in-literal = "deny"
trivial-bounds = "warn"
trivial-casts = "warn"
trivial-numeric-casts = "warn"
type-alias-bounds = "warn"
uncommon-codepoints = "warn"
unconditional-panic = "deny"
unconditional-recursion = "warn"
undropped-manually-drops = "deny"
unexpected-cfgs = "warn"
unfulfilled-lint-expectations = "warn"
ungated-async-fn-track-caller = "warn"
unit-bindings = "warn"
unknown-crate-types = "deny"
unknown-lints = "warn"
unnameable-test-items = "warn"
unnameable-types = "warn"
unnecessary-transmutes = "warn"
unpredictable-function-pointer-comparisons = "warn"
unreachable-cfg-select-predicates = "warn"
unreachable-pub = "warn"
unsafe-code = "forbid"
unstable-features = "forbid"
unused-associated-type-bounds = "warn"
unused-comparisons = "warn"
unused-crate-dependencies = "warn"
unused-import-braces = "warn"
unused-lifetimes = "warn"
unused-qualifications = "warn"
unused-results = "warn"
useless-deprecated = "deny"
useless-ptr-null-checks = "warn"
uses-power-alignment = "warn"
variant-size-differences = "warn"
warnings = "warn"
while-true = "warn"
# unstable lints
# default-overrides-default-fields = "deny"
# deprecated-llvm-intrinsic = "warn"
# implicit-provenance-casts = "deny"
# multiple-supertrait-upcastable = "warn"
# must-not-suspend = "warn"
# non-exhaustive-omitted-patterns = "warn"
# resolving-to-items-shadowing-supertrait-items = "warn"
# shadowing-supertrait-items = "warn"
# tail-call-track-caller = "warn"
# test-unstable-lint = "deny"
# unqualified-local-imports = "deny"
[workspace.lints.rustdoc]
bare-urls = "warn"
broken-intra-doc-links = "warn"
invalid-codeblock-attributes = "warn"
invalid-html-tags = "warn"
invalid-rust-codeblocks = "warn"
missing-crate-level-docs = "warn"
private-doc-tests = "warn"
private-intra-doc-links = "warn"
redundant-explicit-links = "warn"
unescaped-backticks = "warn"
# unstable lints
# missing-doc-code-examples = "warn"
[profile.release]
opt-level = 3
+13 -11
View File
@@ -2,18 +2,20 @@
Libraries and tools for dealing with media metadata
## Various builds
## Development
- build: `cargo hack --feature-powerset build`
- clippy: `cargo hack --feature-powerset clippy -- -D warnings`
- test: `cargo 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`
Run the full validation suite before committing:
```sh
just all
just install
```
##### Publish
- `cargo semver-checks --all-features`
- `cargo publish --dry-run -p <crate>`
- `cargo publish --dry-run --workspace`
## Building flix.db
+9 -24
View File
@@ -1,14 +1,16 @@
[package]
name = "flix-mux"
version = "0.0.20"
license-file.workspace = true
version = "0.1.0"
description = "CLI for bulk media muxing"
repository = "https://github.com/QuantumShade/flix"
categories = ["command-line-utilities"]
repository = "https://git.skrundz.dev/quantumshade/flix"
keywords = ["flix"]
categories = ["command-line-utilities", "multimedia"]
include = ["/src"]
publish = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs]
all-features = true
@@ -31,27 +33,10 @@ clap = { workspace = true, features = [
"usage",
] }
console = { workspace = true }
dialoguer = { workspace = true }
indicatif = { workspace = true }
serde = { workspace = true, features = ["derive", "std"] }
serde_json = { workspace = true, features = ["alloc"] }
walkdir = { workspace = true }
[lints.clippy]
arithmetic_side_effects = "deny"
as_conversions = "deny"
checked_conversions = "deny"
default_union_representation = "deny"
expect_used = "deny"
indexing_slicing = "deny"
integer_division = "deny"
integer_division_remainder_used = "deny"
transmute_undefined_repr = "deny"
unchecked_time_subtraction = "deny"
unwrap_used = "deny"
[lints.rust]
arithmetic_overflow = "forbid"
missing_docs = "forbid"
unsafe_code = "forbid"
unused_doc_comments = "forbid"
[lints]
workspace = true
+30 -20
View File
@@ -1,17 +1,18 @@
use std::path::PathBuf;
//! Command line argument parsing.
use clap::Parser;
use std::path::PathBuf;
use crate::parser::Selector;
#[derive(Parser)]
/// Command line argument parser struct.
#[derive(Debug, clap::Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
/// Dry run and print commands
pub(crate) struct Args {
/// Dry run and print commands.
#[arg(short, long)]
dry_run: bool,
/// Stream selectors
/// Stream selectors.
#[arg(
short,
long,
@@ -21,29 +22,38 @@ pub struct Cli {
)]
selectors: Vec<Selector>,
/// The path to the directory to scan
/// The path to the directory to scan.
#[arg(value_name = "DIR")]
scan_dir: PathBuf,
}
impl Cli {
pub fn is_dry(&self) -> bool {
impl Args {
/// True if the user requested a dry run.
pub(crate) const fn is_dry_run(&self) -> bool {
self.dry_run
}
pub fn selectors(&self) -> &[Selector] {
/// Returns a slice of provided [Selector].
pub(crate) fn selectors(&self) -> &[Selector] {
&self.selectors
}
pub fn scan_dir_path(&self) -> PathBuf {
fn expect_home_dir() -> PathBuf {
#[allow(clippy::expect_used)]
std::env::home_dir().expect("you do not have a home directory")
}
match self.scan_dir.strip_prefix("~/") {
Ok(path) => expect_home_dir().join(path),
Err(_) => self.scan_dir.to_owned(),
}
/// Get the path to scan.
///
/// # Panics
/// If the user doesn't have a home directory.
pub(crate) fn scan_dir_path(&self) -> PathBuf {
self.scan_dir.strip_prefix("~/").map_or_else(
|_| self.scan_dir.clone(),
|path| {
#[expect(
clippy::expect_used,
reason = "without a home directory this program is useless"
)]
std::env::home_dir()
.expect("you do not have a home directory")
.join(path)
},
)
}
}
+14 -9
View File
@@ -1,8 +1,8 @@
//! flix-mux
//! flix-mux.
use core::time::Duration;
use clap::Parser;
use clap::Parser as _;
use console::style;
use indicatif::{HumanBytes, ProgressBar, ProgressStyle};
@@ -16,14 +16,17 @@ mod parser;
mod probe;
mod scan;
#[cfg_attr(test, expect(clippy::missing_panics_doc, reason = "main function"))]
#[expect(clippy::print_stderr, reason = "we want to print in a binary")]
#[expect(clippy::print_stdout, reason = "we want to print in a binary")]
fn main() {
let cli = cli::Cli::parse();
let cli = cli::Args::parse();
let (files, size) = scan_directory(
&cli.scan_dir_path(),
|| {
let progress = ProgressBar::new_spinner();
progress.set_message(format!("Scanning {:?}", &cli.scan_dir_path()));
progress.set_message(format!("Scanning {}", cli.scan_dir_path().display()));
progress.enable_steady_tick(Duration::from_millis(50));
progress
},
@@ -31,7 +34,8 @@ fn main() {
|len| {
let progress = ProgressBar::new(u64::try_from(len).unwrap_or(0));
progress.set_style(
#[expect(clippy::expect_used)]
#[expect(clippy::expect_used, reason = "panic if the template is invalid")]
#[expect(clippy::literal_string_with_formatting_args, reason = "template")]
ProgressStyle::with_template("[{elapsed_precise}] {wide_bar} {pos}/{len} ({msg})")
.expect("static template"),
);
@@ -40,19 +44,20 @@ fn main() {
|progress| progress.inc(1),
|progress| progress.finish_and_clear(),
|progress, msg| {
progress.suspend(|| eprintln!("{} {}", style("[WARN]").bold().yellow(), msg))
progress.suspend(|| eprintln!("{} {msg}", style("[WARN]").bold().yellow()));
},
);
println!("Found {} files ({})", files.len(), HumanBytes(size));
mux_files(
cli.is_dry(),
cli.is_dry_run(),
&files,
cli.selectors(),
|len| {
let progress = ProgressBar::new(u64::try_from(len).unwrap_or(0));
progress.set_style(
#[expect(clippy::expect_used)]
#[expect(clippy::expect_used, reason = "panic if the template is invalid")]
#[expect(clippy::literal_string_with_formatting_args, reason = "template")]
ProgressStyle::with_template("[{elapsed_precise}] {wide_bar} {pos}/{len} ({msg})")
.expect("static template"),
);
@@ -61,6 +66,6 @@ fn main() {
},
|progress| progress.inc(1),
|progress| progress.finish_with_message("done"),
|progress, msg| progress.suspend(|| eprintln!("{} {}", style("[ERR]").bold().red(), msg)),
|progress, msg| progress.suspend(|| eprintln!("{} {msg}", style("[ERR]").bold().red())),
);
}
+51 -16
View File
@@ -1,42 +1,61 @@
//! Model for handling media streams.
use std::path::PathBuf;
use serde::Deserialize;
/// Represents a file that was scanned.
#[derive(Debug, Clone)]
pub struct MediaFile {
pub(crate) struct MediaFile {
/// The path to the file.
pub path: PathBuf,
/// The size of the file.
pub byte_size: u64,
/// The streams in the file.
pub streams: Streams,
}
/// Represents the collection of streams found in a media file.
#[derive(Debug, Clone)]
pub struct Streams {
pub(crate) struct Streams {
/// The video streams.
pub video: Vec<VideoStream>,
/// The audio streams.
pub audio: Vec<AudioStream>,
/// The subtitle streams.
pub subtitle: Vec<SubtitleStream>,
}
pub trait FFStream: serde::de::DeserializeOwned {
/// This trait helps with deserializing `ffmpeg` output.
pub(crate) trait FFStream: serde::de::DeserializeOwned {
/// The type name that `ffmpeg` uses.
const FF_TYPE_NAME: &str;
}
/// Represents an individual video stream in a media file.
#[derive(Debug, Clone, Deserialize)]
pub struct VideoStream {
pub(crate) struct VideoStream {
/// The name of the video codec.
codec_name: String,
/// Additional tags on the stream.
tags: Option<VideoTags>,
}
/// Represents tags on a video stream.
#[derive(Debug, Clone, Deserialize)]
pub struct VideoTags {
pub(crate) struct VideoTags {
/// The language of the stream.
language: Option<String>,
}
impl VideoStream {
pub fn codec(&self) -> &str {
/// Get the video codec name.
pub(crate) fn codec(&self) -> &str {
&self.codec_name
}
pub fn language(&self) -> Option<&str> {
/// Get the stream language.
pub(crate) fn language(&self) -> Option<&str> {
self.tags.as_ref()?.language.as_deref()
}
}
@@ -45,23 +64,30 @@ impl FFStream for VideoStream {
const FF_TYPE_NAME: &str = "v";
}
/// Represents an individual audio stream in a media file.
#[derive(Debug, Clone, Deserialize)]
pub struct AudioStream {
pub(crate) struct AudioStream {
/// The name of the audio codec.
codec_name: String,
/// Additional tags on the stream.
tags: Option<AudioTags>,
}
/// Represents tags on an audio stream.
#[derive(Debug, Clone, Deserialize)]
pub struct AudioTags {
pub(crate) struct AudioTags {
/// The language of the stream.
language: Option<String>,
}
impl AudioStream {
pub fn codec(&self) -> &str {
/// Get the audio codec name.
pub(crate) fn codec(&self) -> &str {
&self.codec_name
}
pub fn language(&self) -> Option<&str> {
/// Get the stream language.
pub(crate) fn language(&self) -> Option<&str> {
self.tags.as_ref()?.language.as_deref()
}
}
@@ -70,28 +96,37 @@ impl FFStream for AudioStream {
const FF_TYPE_NAME: &str = "a";
}
/// Represents an individual subtitle stream in a media file.
#[derive(Debug, Clone, Deserialize)]
pub struct SubtitleStream {
pub(crate) struct SubtitleStream {
/// The name of the subtitle codec.
codec_name: String,
/// Additional tags on the stream.
tags: Option<SubtitleTags>,
}
/// Represents tags on a subtitle stream.
#[derive(Debug, Clone, Deserialize)]
pub struct SubtitleTags {
pub(crate) struct SubtitleTags {
/// The language of the stream.
language: Option<String>,
/// The title of the stream.
title: Option<String>,
}
impl SubtitleStream {
pub fn codec(&self) -> &str {
/// Get the subtitle codec name.
pub(crate) fn codec(&self) -> &str {
&self.codec_name
}
pub fn language(&self) -> Option<&str> {
/// Get the stream language.
pub(crate) fn language(&self) -> Option<&str> {
self.tags.as_ref()?.language.as_deref()
}
pub fn title(&self) -> Option<&str> {
/// Get the stream title.
pub(crate) fn title(&self) -> Option<&str> {
self.tags.as_ref()?.title.as_deref()
}
}
+76 -38
View File
@@ -1,3 +1,5 @@
//! Wrapper around `ffmpeg` to mux media files.
use std::process::Command;
use anyhow::{Context as _, Result};
@@ -5,14 +7,19 @@ use anyhow::{Context as _, Result};
use crate::model::MediaFile;
use crate::parser::{Matcher, Selector, StreamFlag, StreamType};
/// Helper struct for tracking which output index is currently available.
#[derive(Default)]
struct OutputIndex {
/// The next free video index.
video: usize,
/// The next free audio index.
audio: usize,
/// The next free subtitle index.
subtitle: usize,
}
pub fn mux_files<T>(
/// For each input file, apply all selectors and mux the selection.
pub(crate) fn mux_files<T>(
dry_run: bool,
files: &[MediaFile],
selectors: &[Selector],
@@ -24,14 +31,20 @@ pub fn mux_files<T>(
let mut progress = fixed_length_start(files.len());
for file in files {
if let Err(err) = mux(dry_run, file, selectors) {
print_fn(&progress, &format!("{:?}", err));
print_fn(&progress, &format!("{err:?}"));
}
fixed_length_update(&mut progress);
}
fixed_length_end(progress);
}
#[expect(clippy::expect_used)]
/// Apply all selectors and mux the file.
///
/// # Errors
/// If the call to `ffmpeg` fails.
///
/// # Panics
/// If the system fails to allocate a temporary file.
fn mux(dry_run: bool, file: &MediaFile, selectors: &[Selector]) -> Result<()> {
let mut command = Command::new("ffmpeg");
let mut command = command.args(["-v", "error"]);
@@ -40,10 +53,14 @@ fn mux(dry_run: bool, file: &MediaFile, selectors: &[Selector]) -> Result<()> {
command = command.arg(file.path.as_os_str());
for selector in selectors {
command = command.args(
make_map_args(file, selector)
.with_context(|| format!("Failed to mux {:?}", file.path))?,
);
command = command.args(make_map_args(file, selector).with_context(|| {
let info = if dry_run {
format!("Streams:\n{file:#?}")
} else {
String::from("use -d for stream info")
};
format!("Failed to mux {}\n{info}", file.path.display())
})?);
}
command = command.args(["-c:v", "copy", "-c:a", "copy", "-c:s", "mov_text"]);
@@ -56,25 +73,34 @@ fn mux(dry_run: bool, file: &MediaFile, selectors: &[Selector]) -> Result<()> {
let mut ouput_index = OutputIndex::default();
for selector in selectors {
command = command.args(
make_metadata_args(file, selector, &mut ouput_index)
.with_context(|| format!("Failed to mux {:?}", file.path))?,
make_metadata_args(file, selector, &mut ouput_index).with_context(|| {
let info = if dry_run {
format!("Streams:\n{file:#?}")
} else {
String::from("use -d for stream info")
};
format!("Failed to mux {}\n{info}", file.path.display())
})?,
);
}
let temp_path = file.path.with_extension("mp4");
#[expect(clippy::expect_used, reason = "files must have names")]
{
command = command.arg(temp_path.file_name().expect("file name exists"));
}
if dry_run {
print_command(command);
} else {
let output = command
.output()
.with_context(|| format!("Failed to mux {:?}", file.path))?;
.with_context(|| format!("Failed to mux {}", file.path.display()))?;
if !output.status.success() {
anyhow::bail!(
"ffmpeg failed for {:?}:\n\n{}",
file.path,
"ffmpeg failed for {}:\n\n{}",
file.path.display(),
String::from_utf8_lossy(&output.stderr)
);
}
@@ -83,23 +109,31 @@ fn mux(dry_run: bool, file: &MediaFile, selectors: &[Selector]) -> Result<()> {
Ok(())
}
/// Generate the `-map` argument list for `ffmpeg`.
///
/// # Errors
/// If the stream selection is unsatisfyable for the file.
fn make_map_args(file: &MediaFile, selector: &Selector) -> Result<Vec<String>> {
let source_index = 0;
let source_index: u32 = 0;
let stream_type = selector.stream_type.as_ref();
let Some(stream_index) = find_stream_index(file, selector) else {
if selector.optional {
return Ok(vec![]);
} else {
anyhow::bail!("unsatisfied stream selection");
}
anyhow::bail!("unsatisfied stream selection");
};
Ok(vec![
String::from("-map"),
format!("{}:{}:{}", source_index, stream_type, stream_index),
format!("{source_index}:{stream_type}:{stream_index}"),
])
}
/// Generate the `-metadata` argument list for `ffmpeg`.
///
/// # Errors
/// If the stream selection is unsatisfyable for the file.
fn make_metadata_args(
file: &MediaFile,
selector: &Selector,
@@ -112,9 +146,9 @@ fn make_metadata_args(
let Some(_) = find_stream_index(file, selector) else {
if selector.optional {
return Ok(vec![]);
} else {
anyhow::bail!("unsatisfied stream selection");
}
anyhow::bail!("unsatisfied stream selection");
};
let counter = match selector.stream_type {
@@ -126,8 +160,8 @@ fn make_metadata_args(
*counter = counter.saturating_add(1);
let mut args = vec![
format!("-metadata:s:{}:{}", stream_type, stream_index),
format!("language={}", stream_language),
format!("-metadata:s:{stream_type}:{stream_index}"),
format!("language={stream_language}"),
];
if selector.stream_type == StreamType::Subtitle {
@@ -137,25 +171,26 @@ fn make_metadata_args(
None => match stream_language {
"eng" => "English",
"jpn" => "Japanese",
_ => anyhow::bail!("Unhandled subtitle language: {}", stream_language),
_ => anyhow::bail!("Unhandled subtitle language: {stream_language}"),
},
};
args.push(format!("-metadata:s:s:{}", stream_index));
args.push(format!("title={}", sub_title));
args.push(format!("-metadata:s:s:{stream_index}"));
args.push(format!("title={sub_title}"));
}
Ok(args)
}
/// Walk the streams of a file to match the given [Selector].
fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
let needs_forced = selector.flag == Some(StreamFlag::Forced);
let needs_sdh = selector.flag == Some(StreamFlag::Sdh);
match selector.stream_type {
StreamType::Video => match selector.matcher {
Matcher::Index(index) => (file.streams.video.len() > index).then_some(index),
Matcher::Language(ref language) => file
StreamType::Video => match &selector.matcher {
Matcher::Index(index) => (file.streams.video.len() > *index).then_some(*index),
Matcher::Language(language) => file
.streams
.video
.iter()
@@ -163,7 +198,7 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
.filter(|(_, c)| c.language() == Some(language.as_str()))
.map(|(i, _)| i)
.next(),
Matcher::Codec(ref codec) => file
Matcher::Codec(codec) => file
.streams
.video
.iter()
@@ -172,9 +207,9 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
.map(|(i, _)| i)
.next(),
},
StreamType::Audio => match selector.matcher {
Matcher::Index(index) => (file.streams.audio.len() > index).then_some(index),
Matcher::Language(ref language) => file
StreamType::Audio => match &selector.matcher {
Matcher::Index(index) => (file.streams.audio.len() > *index).then_some(*index),
Matcher::Language(language) => file
.streams
.audio
.iter()
@@ -182,7 +217,7 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
.filter(|(_, c)| c.language() == Some(language.as_str()))
.map(|(i, _)| i)
.next(),
Matcher::Codec(ref codec) => file
Matcher::Codec(codec) => file
.streams
.audio
.iter()
@@ -191,9 +226,9 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
.map(|(i, _)| i)
.next(),
},
StreamType::Subtitle => match selector.matcher {
Matcher::Index(index) => (file.streams.subtitle.len() > index).then_some(index),
Matcher::Language(ref language) => {
StreamType::Subtitle => match &selector.matcher {
Matcher::Index(index) => (file.streams.subtitle.len() > *index).then_some(*index),
Matcher::Language(language) => {
file.streams
.subtitle
.iter()
@@ -214,7 +249,7 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
.map(|(i, _)| i)
.next()
}
Matcher::Codec(ref codec) => file
Matcher::Codec(codec) => file
.streams
.subtitle
.iter()
@@ -226,6 +261,8 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
}
}
/// Print out the command to stdout.
#[expect(clippy::print_stdout, reason = "the purpose is to print")]
fn print_command(cmd: &Command) {
let program = cmd.get_program().to_string_lossy();
@@ -235,15 +272,16 @@ fn print_command(cmd: &Command) {
.collect::<Vec<_>>()
.join(" ");
println!("{} {}", program, args);
println!("{program} {args}");
}
/// Escape a string to be used by a shell.
fn shell_escape(s: &str) -> String {
if s.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c))
{
s.to_string()
s.to_owned()
} else {
format!("{:?}", s)
format!("{s:?}")
}
}
+49 -22
View File
@@ -1,20 +1,26 @@
//! Parsing types for `ffmpeg` output.
use core::error::Error;
use core::fmt;
use core::str::FromStr;
/// The type of a stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamType {
pub(crate) enum StreamType {
/// A video stream.
Video,
/// An audio stream.
Audio,
/// A subtitle stream.
Subtitle,
}
impl AsRef<str> for StreamType {
fn as_ref(&self) -> &str {
match self {
StreamType::Video => "v",
StreamType::Audio => "a",
StreamType::Subtitle => "s",
Self::Video => "v",
Self::Audio => "a",
Self::Subtitle => "s",
}
}
}
@@ -24,18 +30,22 @@ impl FromStr for StreamType {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"v" => Ok(StreamType::Video),
"a" => Ok(StreamType::Audio),
"s" => Ok(StreamType::Subtitle),
other => Err(ParseSelectorError::InvalidStreamType(other.to_string())),
"v" => Ok(Self::Video),
"a" => Ok(Self::Audio),
"s" => Ok(Self::Subtitle),
other => Err(ParseSelectorError::InvalidStreamType(other.to_owned())),
}
}
}
/// A property of a stream to match on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Matcher {
pub(crate) enum Matcher {
/// Match a specific stream index.
Index(usize),
/// Match the first stream of the given language.
Language(String),
/// Match the first stream of the given codec.
Codec(String),
}
@@ -44,19 +54,22 @@ impl FromStr for Matcher {
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Ok(idx) = s.parse::<usize>() {
return Ok(Matcher::Index(idx));
return Ok(Self::Index(idx));
}
if s.len() == 3 {
return Ok(Matcher::Language(s.to_ascii_lowercase()));
return Ok(Self::Language(s.to_ascii_lowercase()));
}
Ok(Matcher::Codec(s.to_ascii_lowercase()))
Ok(Self::Codec(s.to_ascii_lowercase()))
}
}
/// Flags applied to streams.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamFlag {
pub(crate) enum StreamFlag {
/// Forced flag.
Forced,
/// SDH flag.
Sdh,
}
@@ -65,24 +78,31 @@ impl FromStr for StreamFlag {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"forced" => Ok(StreamFlag::Forced),
"sdh" => Ok(StreamFlag::Sdh),
other => Err(ParseSelectorError::InvalidFlag(other.to_string())),
"forced" => Ok(Self::Forced),
"sdh" => Ok(Self::Sdh),
other => Err(ParseSelectorError::InvalidFlag(other.to_owned())),
}
}
}
/// A complete selector which has everything it needs to find a specific stream.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Selector {
pub(crate) struct Selector {
/// The type of the stream to select.
pub stream_type: StreamType,
/// The matcher to use when selecting.
pub matcher: Matcher,
/// A desired flag.
pub flag: Option<StreamFlag>,
/// Whether or not to bail if the stream cannot be found.
pub optional: bool,
/// The output language of the stream.
pub out_lang: String,
}
impl Selector {
pub fn language(&self) -> &str {
/// Get the target language of the selector.
pub(crate) fn language(&self) -> &str {
&self.out_lang
}
}
@@ -125,7 +145,7 @@ impl FromStr for Selector {
(true, _) => return Err(ParseSelectorError::UnspecifiedLanguage),
};
Ok(Selector {
Ok(Self {
stream_type,
matcher,
flag,
@@ -135,15 +155,22 @@ impl FromStr for Selector {
}
}
/// Errors that can arise when parsing a selector string.
#[derive(Debug)]
pub enum ParseSelectorError {
pub(crate) enum ParseSelectorError {
/// The selector string is empty.
Empty,
/// The selector string has an invalid format.
InvalidFormat,
/// The requested stream type in invalid.
InvalidStreamType(String),
/// The requested flag is invalid.
InvalidFlag(String),
/// The target language is unspecified.
UnspecifiedLanguage,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl Error for ParseSelectorError {}
impl fmt::Display for ParseSelectorError {
@@ -151,8 +178,8 @@ impl fmt::Display for ParseSelectorError {
match self {
Self::Empty => write!(f, "selector was empty"),
Self::InvalidFormat => write!(f, "invalid selector format"),
Self::InvalidStreamType(s) => write!(f, "invalid stream type '{}'", s),
Self::InvalidFlag(s) => write!(f, "invalid stream flag '{}'", s),
Self::InvalidStreamType(s) => write!(f, "invalid stream type '{s}'"),
Self::InvalidFlag(s) => write!(f, "invalid stream flag '{s}'"),
Self::UnspecifiedLanguage => write!(f, "unspecified output language"),
}
}
+21 -6
View File
@@ -1,4 +1,6 @@
use std::os::unix::fs::MetadataExt;
//! Helpers for using `ffprobe` to gather stream information from media files.
use std::os::unix::fs::MetadataExt as _;
use std::process::Command;
use anyhow::{Context as _, Result};
@@ -7,11 +9,20 @@ use walkdir::DirEntry;
use crate::model::{FFStream, MediaFile, Streams};
/// The top-level containing type for `ffprobe` output.
#[derive(Debug, Deserialize)]
struct FFProbeOutput<S> {
/// The list of returned streams.
streams: Vec<S>,
}
/// Calls `ffprobe` on a [`DirEntry`] to gather one type of stream.
///
/// # Errors
/// Forwards any IO or parsing errors.
///
/// # Panics
/// If the file path is not utf8.
fn probe_file_streams<S: FFStream>(entry: &DirEntry) -> Result<Vec<S>> {
let output = Command::new("ffprobe")
.args([
@@ -22,16 +33,16 @@ fn probe_file_streams<S: FFStream>(entry: &DirEntry) -> Result<Vec<S>> {
"-show_streams",
"-select_streams",
S::FF_TYPE_NAME,
#[expect(clippy::expect_used)]
#[expect(clippy::expect_used, reason = "panic if path is not utf8")]
entry.path().to_str().expect("path should be utf8"),
])
.output()
.with_context(|| format!("Failed to run ffprobe on {:?}", entry.path()))?;
.with_context(|| format!("Failed to run ffprobe on {}", entry.path().display()))?;
if !output.status.success() {
anyhow::bail!(
"ffprobe failed for {:?}:\n\n{}",
entry.path(),
"ffprobe failed for {}:\n\n{}",
entry.path().display(),
String::from_utf8_lossy(&output.stderr)
);
}
@@ -40,7 +51,11 @@ fn probe_file_streams<S: FFStream>(entry: &DirEntry) -> Result<Vec<S>> {
Ok(parsed.streams)
}
pub fn probe_file(entry: &DirEntry) -> Result<MediaFile> {
/// Calls `ffprobe` on a [`DirEntry`] to detect all streams.
///
/// # Errors
/// Forwards any IO or parsing errors.
pub(crate) fn probe_file(entry: &DirEntry) -> Result<MediaFile> {
Ok(MediaFile {
path: entry.path().to_path_buf(),
byte_size: entry.metadata()?.size(),
+12 -15
View File
@@ -1,3 +1,5 @@
//! Helpers for filesystem scanning.
use std::path::Path;
use walkdir::WalkDir;
@@ -5,7 +7,8 @@ use walkdir::WalkDir;
use crate::model::MediaFile;
use crate::probe::probe_file;
pub fn scan_directory<T>(
/// Recursively scan a directory.
pub(crate) fn scan_directory<T>(
path: &Path,
unknown_length_start: impl FnOnce() -> T,
unknown_length_end: impl FnOnce(T),
@@ -18,32 +21,26 @@ pub fn scan_directory<T>(
let files: Vec<_> = WalkDir::new(path)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
.filter(|e| {
let is_mkv = e
.path()
.extension()
.unwrap_or_default()
.eq_ignore_ascii_case("mkv");
let is_mp4 = e
.path()
.extension()
.unwrap_or_default()
.eq_ignore_ascii_case("mp4");
is_mkv || is_mp4
#[expect(clippy::filetype_is_file, reason = "we only want regular files")]
e.file_type().is_file()
})
.filter(|e| {
let ext = e.path().extension().unwrap_or_default();
ext.eq_ignore_ascii_case("mkv") || ext.eq_ignore_ascii_case("mp4")
})
.collect();
unknown_length_end(spinner);
let mut progress = fixed_length_start(files.len());
let mut total_byte_size = 0u64;
let mut total_byte_size = 0_u64;
let files: Vec<_> = files
.iter()
.filter_map(|entry| {
let file = match probe_file(entry) {
Ok(file) => Some(file),
Err(err) => {
print_fn(&progress, &format!("{:?}", err));
print_fn(&progress, &format!("{err:?}"));
None
}
};
+9 -24
View File
@@ -1,14 +1,16 @@
[package]
name = "flix-cli"
version = "0.0.20"
license-file.workspace = true
version = "0.1.0"
description = "CLI for interacting with a flix database"
repository = "https://github.com/QuantumShade/flix"
categories = ["command-line-utilities"]
repository = "https://git.skrundz.dev/quantumshade/flix"
keywords = ["flix"]
categories = ["command-line-utilities", "multimedia"]
include = ["/src"]
publish = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs]
all-features = true
@@ -32,7 +34,6 @@ clap = { workspace = true, features = [
"usage",
] }
flix = { workspace = true, features = ["tmdb"] }
futures = { workspace = true }
sea-orm = { workspace = true, features = ["debug-print", "runtime-tokio"] }
serde = { workspace = true, features = ["derive"] }
tokio = { workspace = true, features = ["fs", "macros", "rt"] }
@@ -40,21 +41,5 @@ toml = { workspace = true, features = ["parse", "serde"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[lints.clippy]
arithmetic_side_effects = "deny"
as_conversions = "deny"
checked_conversions = "deny"
default_union_representation = "deny"
expect_used = "deny"
indexing_slicing = "deny"
integer_division = "deny"
integer_division_remainder_used = "deny"
transmute_undefined_repr = "deny"
unchecked_time_subtraction = "deny"
unwrap_used = "deny"
[lints.rust]
arithmetic_overflow = "forbid"
missing_docs = "forbid"
unsafe_code = "forbid"
unused_doc_comments = "forbid"
[lints]
workspace = true
+15 -4
View File
@@ -1,29 +1,40 @@
//! Command line argument parsing for the `flix` subcommand.
use flix::model::numbers::{EpisodeNumber, SeasonNumber};
use chrono::NaiveDate;
use clap::Subcommand;
/// Subcommand for adding `flix` media.
#[derive(Subcommand)]
pub enum AddCommand {
/// Add a flix collection
pub(crate) enum AddCommand {
/// Add a flix collection.
Collection {
/// The collection's title.
#[arg(value_name = "TITLE")]
title: String,
/// The collection's overview.
#[arg(value_name = "OVERVIEW")]
overview: String,
},
/// Add a flix episode
/// Add a flix episode.
Episode {
/// The episode's show's web slug.
#[arg(value_name = "SHOW_WEB_SLUG")]
show_slug: String,
show_web_slug: String,
/// The episode's season number.
#[arg(value_name = "NUMBER")]
season_number: SeasonNumber,
/// The episode's number.
#[arg(value_name = "NUMBER")]
episode_number: EpisodeNumber,
/// The episode's title.
#[arg(value_name = "TITLE")]
title: String,
/// The episode's overview.
#[arg(value_name = "OVERVIEW")]
overview: String,
/// The episode's air date.
#[arg(value_name = "DATE")]
air_date: NaiveDate,
},
+68 -52
View File
@@ -1,15 +1,18 @@
//! Command line argument parsing.
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use clap::{Args, Parser, Subcommand};
pub mod flix;
pub mod tmdb;
pub(crate) mod flix;
pub(crate) mod tmdb;
/// Command line argument parser struct.
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
/// Use a custom config file
pub(crate) struct Cli {
/// Use a custom config file.
#[arg(
short,
long,
@@ -17,41 +20,53 @@ pub struct Cli {
default_value = "~/.config/flix/config.toml"
)]
config: PathBuf,
/// Use a custom cache file
/// Use a custom cache file.
#[arg(short = 'C', long, value_name = "FILE", default_value = "./flix.redb")]
cache: PathBuf,
/// Use a custom database file
/// Use a custom database file.
#[arg(short, long, value_name = "FILE", default_value = "./flix.db")]
database: PathBuf,
/// Enable tracing
/// Enable tracing.
#[arg(short, long)]
pub trace: bool,
trace: bool,
/// Subcommand to execute.
#[command(subcommand)]
command: Command,
}
impl Cli {
pub fn config_path(&self) -> PathBuf {
fn expect_home_dir() -> PathBuf {
#[allow(clippy::expect_used)]
std::env::home_dir().expect("you do not have a home directory")
/// Get the config file path.
///
/// # Panics
/// If the user doesn't have a home directory.
#[inline]
pub(crate) fn config_path(&self) -> PathBuf {
self.config.strip_prefix("~/").map_or_else(
|_| self.config.clone(),
|path| {
#[expect(
clippy::expect_used,
reason = "without a home directory this program is useless"
)]
std::env::home_dir()
.expect("you do not have a home directory")
.join(path)
},
)
}
match self.config.strip_prefix("~/") {
Ok(path) => expect_home_dir().join(path),
Err(_) => self.config.to_owned(),
}
}
pub fn cache_path(&self) -> &Path {
/// Get the path to the cache file.
#[inline]
pub(crate) fn cache_path(&self) -> &Path {
&self.cache
}
pub fn database_path(&self) -> Result<String> {
/// Get the path to the destination database.
///
/// # Errors
/// If the database path is not utf8.
#[inline]
pub(crate) fn database_path(&self) -> Result<String> {
self.database
.as_os_str()
.to_str()
@@ -59,77 +74,78 @@ impl Cli {
.ok_or_else(|| anyhow!(".as_os_str().to_str()"))
}
pub fn command(self) -> Command {
/// Get whether or not the trace flag was enabled.
#[inline]
pub(crate) const fn trace(&self) -> bool {
self.trace
}
/// Get the subcommand to run.
#[inline]
pub(crate) fn get_command(self) -> Command {
self.command
}
}
/// Optional overrides for media information.
#[derive(Args)]
pub struct AddOverrides {
pub(crate) struct AddOverrides {
/// Override the displayed title.
#[arg(long)]
pub title: Option<String>,
/// Override the title used to sort.
#[arg(long)]
pub sort_title: Option<String>,
/// Override the filesystem slug.
#[arg(long)]
pub fs_slug: Option<String>,
/// Overrride the web slug.
#[arg(long)]
pub web_slug: Option<String>,
}
/// Top level cli commands.
#[derive(Subcommand)]
pub enum Command {
/// Initialize a new database
pub(crate) enum Command {
/// Initialize a new database.
Init,
/// Add new items to the database
/// Add new items to the database.
Add {
/// Overrides.
#[command(flatten)]
overrides: AddOverrides,
/// Command.
#[command(subcommand)]
command: AddCommand,
},
/// Update an existing item in the database
Update {
#[command(subcommand)]
command: UpdateCommand,
},
}
/// Wrapper for `add` around different backends.
#[derive(Subcommand)]
pub enum AddCommand {
/// Use the flix backend
pub(crate) enum AddCommand {
/// Use the flix backend.
Flix {
/// Command backend.
#[command(subcommand)]
command: flix::AddCommand,
},
/// Use the TMDB backend
/// Use the TMDB backend.
Tmdb {
/// Command backend.
#[command(subcommand)]
command: tmdb::Command,
},
}
impl From<flix::AddCommand> for AddCommand {
#[inline]
fn from(value: flix::AddCommand) -> Self {
Self::Flix { command: value }
}
}
impl From<tmdb::Command> for AddCommand {
fn from(value: tmdb::Command) -> Self {
Self::Tmdb { command: value }
}
}
#[derive(Subcommand)]
pub enum UpdateCommand {
/// Use the TMDB backend
Tmdb {
#[command(subcommand)]
command: tmdb::Command,
},
}
impl From<tmdb::Command> for UpdateCommand {
#[inline]
fn from(value: tmdb::Command) -> Self {
Self::Tmdb { command: value }
}
+24 -12
View File
@@ -1,41 +1,53 @@
//! Command line argument parsing for the `tmdb` subcommand.
use flix::model::numbers::{EpisodeNumber, SeasonNumber};
use flix::tmdb::model::id::RawId;
use flix::tmdb::model::id::TmdbRepr;
use clap::Subcommand;
/// Subcommand for adding `tmdb` media.
#[derive(Subcommand)]
pub enum Command {
/// Process a TMDB collection
pub(crate) enum Command {
/// Process a TMDB collection.
Collection {
/// The collection's ID.
#[arg(value_name = "TMDB_ID")]
id: RawId,
id: TmdbRepr,
},
/// Process a TMDB movie
/// Process a TMDB movie.
Movie {
/// The movie's ID.
#[arg(value_name = "TMDB_ID")]
id: RawId,
id: TmdbRepr,
},
/// Process a TMDB show
/// Process a TMDB show.
Show {
/// The show's ID.
#[arg(value_name = "TMDB_ID")]
id: RawId,
id: TmdbRepr,
},
/// Process a TMDB season
/// Process a TMDB season.
Season {
/// The season's show's ID.
#[arg(value_name = "TMDB_ID")]
id: RawId,
id: TmdbRepr,
/// The season's number.
#[arg(value_name = "SEASON_NUM")]
season: SeasonNumber,
},
/// Process a TMDB episode
/// Process a TMDB episode.
#[command(trailing_var_arg = true)]
Episode {
/// The episode's show's ID.
#[arg(value_name = "TMDB_ID")]
id: RawId,
id: TmdbRepr,
/// The episode's season's number.
#[arg(value_name = "SEASON_NUM")]
season: SeasonNumber,
/// The episode's number.
#[arg(value_name = "EPISODE_NUM")]
episode: EpisodeNumber,
/// Additional episode numbers for merged media files.
#[arg(value_name = "...")]
episodes: Vec<EpisodeNumber>,
},
+12 -4
View File
@@ -1,21 +1,29 @@
//! CLI configuration.
/// Top level config struct.
#[derive(serde::Deserialize)]
pub struct Config {
pub(crate) struct Config {
/// The TMDB config.
tmdb: TmdbConfig,
}
/// TMDB config struct.
#[derive(serde::Deserialize)]
pub struct TmdbConfig {
pub(crate) struct TmdbConfig {
/// The bearer token to use for API requests.
bearer_token: String,
}
impl Config {
pub fn tmdb(&self) -> &TmdbConfig {
/// Get the TMDB config.
pub(crate) const fn tmdb(&self) -> &TmdbConfig {
&self.tmdb
}
}
impl TmdbConfig {
pub fn bearer_token(&self) -> &str {
/// Get the bearer token.
pub(crate) fn bearer_token(&self) -> &str {
&self.bearer_token
}
}
+22 -6
View File
@@ -1,9 +1,17 @@
//! Databse helpers.
use std::path::Path;
use flix::db::connection::Connection;
use anyhow::{Context, Result, bail};
use anyhow::{Context as _, Result, bail};
use sea_orm::{ConnectOptions, Database};
use tokio::fs;
/// Connect to a database using a connection string.
///
/// # Errors
/// If the database cannot be opened.
async fn connect(string: String) -> Result<Connection> {
Connection::try_from(
Database::connect(ConnectOptions::new(string))
@@ -14,14 +22,22 @@ async fn connect(string: String) -> Result<Connection> {
.context("Connection::try_from")
}
pub async fn open(database_path: String) -> Result<Connection> {
connect(format!("sqlite:{database_path}?mode=rw")).await
/// Helper for opening an existing database at the given path.
///
/// # Errors
/// If the database cannot be opened.
pub(crate) async fn open(path: &Path) -> Result<Connection> {
connect(format!("sqlite:{}?mode=rw", path.display())).await
}
pub async fn open_new(database_path: String) -> Result<Connection> {
if fs::try_exists(&database_path).await? {
/// Helper for creating then opening a database at the given path.
///
/// # Errors
/// If the database already exists or cannot be openend after creation.
pub(crate) async fn open_new(path: &Path) -> Result<Connection> {
if fs::try_exists(path).await? {
bail!("database already exists");
}
connect(format!("sqlite:{database_path}?mode=rwc")).await
connect(format!("sqlite:{}?mode=rwc", path.display())).await
}
+29 -27
View File
@@ -1,15 +1,19 @@
//! flix-cli
//! flix-cli.
use std::rc::Rc;
extern crate alloc;
use std::path::Path;
use alloc::sync::Arc;
use flix::tmdb::{self, CachePolicy, Client, RedbCache};
use anyhow::{Context, Result};
use clap::Parser;
use anyhow::{Context as _, Result};
use clap::Parser as _;
use tokio::fs;
mod cli;
use cli::{AddCommand, Cli, Command, UpdateCommand};
use cli::{AddCommand, Cli, Command};
mod config;
use config::Config;
@@ -20,47 +24,57 @@ mod db;
mod run;
#[tokio::main(flavor = "current_thread")]
#[cfg_attr(test, expect(clippy::missing_errors_doc, reason = "main function"))]
#[cfg_attr(test, expect(clippy::missing_panics_doc, reason = "main function "))]
async fn main() -> Result<()> {
let cli = Cli::parse();
let config = fs::read_to_string(cli.config_path())
.await
.with_context(|| format!("could not read config: {:?}", cli.config_path()))?;
.with_context(|| format!("could not read config: {}", cli.config_path().display()))?;
let config: Config = toml::from_str(config.as_str())
.with_context(|| format!("could not parse config: {:?}", cli.config_path()))?;
.with_context(|| format!("could not parse config: {}", cli.config_path().display()))?;
let database_path = cli.database_path()?;
let database_path = Path::new(&database_path);
let config = tmdb::Config::new(config.tmdb().bearer_token().to_owned());
let cache = Rc::new(RedbCache::new(cli.cache_path())?);
let cache = Arc::new(RedbCache::new(cli.cache_path())?);
let client = Client::new(config, cache, CachePolicy::Full);
if cli.trace {
if cli.trace() {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.with_test_writer()
.init();
}
match cli.command() {
match cli.get_command() {
Command::Init => exec_init(database_path).await?,
Command::Add { command, overrides } => {
exec_add(client, database_path, command, overrides).await?
exec_add(client, database_path, command, overrides).await?;
}
Command::Update { command } => exec_update(client, database_path, command).await?,
}
Ok(())
}
async fn exec_init(database_path: String) -> Result<()> {
db::open_new(database_path).await?;
/// Execute the `init` command.
///
/// # Errors
/// Forwards any errors.
async fn exec_init(database_path: &Path) -> Result<()> {
drop(db::open_new(database_path).await?);
Ok(())
}
/// Execute the `add` command.
///
/// # Errors
/// Forwards any errors.
async fn exec_add(
client: Client,
database_path: String,
database_path: &Path,
command: AddCommand,
overrides: AddOverrides,
) -> Result<()> {
@@ -77,15 +91,3 @@ async fn exec_add(
Ok(())
}
async fn exec_update(client: Client, database_path: String, command: UpdateCommand) -> Result<()> {
let database = db::open(database_path).await?;
match command {
UpdateCommand::Tmdb { command } => {
run::tmdb::update(client, database.as_ref(), command).await?;
}
}
Ok(())
}
+24 -13
View File
@@ -1,3 +1,5 @@
//! The `flix` runtime backend.
use flix::db::entity;
use flix::model::id::{CollectionId, ShowId};
use flix::model::numbers::{EpisodeNumber, SeasonNumber};
@@ -5,12 +7,19 @@ use flix::model::text;
use anyhow::Result;
use sea_orm::ActiveValue::{NotSet, Set};
use sea_orm::{ActiveModelTrait, DatabaseConnection, DbErr, TransactionError, TransactionTrait};
use sea_orm::{
ActiveModelTrait as _, DatabaseConnection, DbErr, TransactionError, TransactionTrait as _,
};
use crate::cli::AddOverrides;
use crate::cli::flix::AddCommand;
pub async fn add(
/// Execute an `add` command.
///
/// # Errors
/// Forwards any database transaction failures.
#[expect(clippy::print_stdout, reason = "we want to print in a binary")]
pub(crate) async fn add(
db: &DatabaseConnection,
command: AddCommand,
overrides: AddOverrides,
@@ -50,15 +59,16 @@ pub async fn add(
match result {
Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?,
};
println!("Created Collection: {}", title);
Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
return Err(err.into());
}
}
println!("Created Collection: {title}");
Ok(())
}
AddCommand::Episode {
show_slug,
show_web_slug,
season_number,
episode_number,
title,
@@ -70,11 +80,11 @@ pub async fn add(
let title = overrides.title.unwrap_or_else(|| title.clone());
Box::pin(async move {
let show = entity::info::shows::Entity::find_by_web_slug(&show_slug)
let show = entity::info::shows::Entity::find_by_web_slug(&show_web_slug)
.one(txn)
.await?
.ok_or_else(|| {
DbErr::Custom(format!("show '{}' does not exist", show_slug))
DbErr::Custom(format!("show '{show_web_slug}' does not exist"))
})?;
let flix = entity::info::episodes::ActiveModel {
@@ -95,10 +105,11 @@ pub async fn add(
match result {
Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?,
};
println!("Created Episode: {}", title);
Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
return Err(err.into());
}
}
println!("Created Episode: {title}");
Ok(())
}
+4 -2
View File
@@ -1,2 +1,4 @@
pub mod flix;
pub mod tmdb;
//! Various runtime backends.
pub(crate) mod flix;
pub(crate) mod tmdb;
+92 -623
View File
@@ -1,4 +1,6 @@
use std::collections::HashMap;
//! The `flix` runtime backend.
use alloc::collections::BTreeMap;
use flix::db::entity;
use flix::model::id::{CollectionId, MovieId, ShowId};
@@ -9,17 +11,24 @@ use flix::tmdb::model::id::{
CollectionId as TmdbCollectionId, MovieId as TmdbMovieId, ShowId as TmdbShowId,
};
use anyhow::{Context, Result, bail};
use chrono::{Datelike, Utc};
use anyhow::{Context as _, Result, bail};
use chrono::{Datelike as _, Utc};
use sea_orm::ActiveValue::{NotSet, Set};
use sea_orm::{
ActiveModelTrait, DatabaseConnection, DbErr, EntityTrait, TransactionError, TransactionTrait,
ActiveModelTrait as _, DatabaseConnection, DbErr, EntityTrait as _, TransactionError,
TransactionTrait as _,
};
use crate::cli::AddOverrides;
use crate::cli::tmdb::Command;
pub async fn add(
/// Execute an `add` command.
///
/// # Errors
/// Forwards any database transaction failures or TMDB API failures.
#[expect(clippy::print_stderr, reason = "we want to print in a binary")]
#[expect(clippy::print_stdout, reason = "we want to print in a binary")]
pub(crate) async fn add(
client: Client,
db: &DatabaseConnection,
command: Command,
@@ -54,6 +63,7 @@ pub async fn add(
.web_slug
.unwrap_or_else(|| text::make_web_slug(&title));
{
const COLLECTION_SUFFIX_TO_REMOVE: &str = "-collection";
if web_slug.ends_with(COLLECTION_SUFFIX_TO_REMOVE) {
web_slug.truncate(
@@ -62,6 +72,7 @@ pub async fn add(
.saturating_sub(COLLECTION_SUFFIX_TO_REMOVE.len()),
);
}
}
let result: Result<CollectionId, TransactionError<DbErr>> = db
.transaction(|txn| {
@@ -78,7 +89,7 @@ pub async fn add(
.insert(txn)
.await?;
entity::tmdb::collections::ActiveModel {
_ = entity::tmdb::collections::ActiveModel {
tmdb_id: Set(id),
flix_id: Set(flix.id),
last_update: Set(Utc::now()),
@@ -94,10 +105,11 @@ pub async fn add(
match result {
Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?,
};
println!("Created Collection: {}", title);
Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
return Err(err.into());
}
}
println!("Created Collection: {title}");
Ok(())
}
@@ -145,7 +157,7 @@ pub async fn add(
.insert(txn)
.await?;
entity::tmdb::movies::ActiveModel {
_ = entity::tmdb::movies::ActiveModel {
tmdb_id: Set(id),
flix_id: Set(flix.id),
last_update: Set(Utc::now()),
@@ -162,10 +174,11 @@ pub async fn add(
match result {
Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?,
};
println!("Created Movie: {} ({})", title, year);
Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
return Err(err.into());
}
}
println!("Created Movie: {title} ({year})");
Ok(())
}
@@ -183,7 +196,7 @@ pub async fn add(
.await
.with_context(|| format!("shows().get_details({})", id.into_raw()))?;
let mut seasons = Vec::new();
let mut episodes = HashMap::new();
let mut episodes = BTreeMap::new();
for season in 1..=show.number_of_seasons {
let season = SeasonNumber::new(season);
@@ -191,12 +204,11 @@ pub async fn add(
.seasons()
.get_details(id, season, None)
.await
.with_context(|| {
format!("seasons().get_details({}, {})", id.into_raw(), season)
}) {
.with_context(|| format!("seasons().get_details({}, {season})", id.into_raw()))
{
Ok(season) => season,
Err(err) => {
eprintln!("{err:?}");
eprintln!("{err}");
continue;
}
};
@@ -227,11 +239,9 @@ pub async fn add(
Ok(value) => value,
Err(err) => {
eprintln!(
"skipping episode ({}, {}, {}) - {}",
"skipping episode ({}, {}, {episode}) - {err}",
id.into_raw(),
season.season_number,
episode,
err
);
break;
}
@@ -239,7 +249,7 @@ pub async fn add(
season_episodes.push(episode);
}
episodes.insert(season.season_number, season_episodes);
drop(episodes.insert(season.season_number, season_episodes));
seasons.push(season);
}
@@ -273,7 +283,7 @@ pub async fn add(
.insert(txn)
.await?;
entity::tmdb::shows::ActiveModel {
_ = entity::tmdb::shows::ActiveModel {
tmdb_id: Set(id),
flix_id: Set(flix.id),
last_update: Set(Utc::now()),
@@ -283,6 +293,7 @@ pub async fn add(
.await?;
for season in seasons {
drop(
entity::info::seasons::ActiveModel {
show_id: Set(flix.id),
season_number: Set(season.season_number),
@@ -291,9 +302,10 @@ pub async fn add(
date: Set(season.air_date),
}
.insert(txn)
.await?;
.await?,
);
entity::tmdb::seasons::ActiveModel {
_ = entity::tmdb::seasons::ActiveModel {
tmdb_show: Set(id),
tmdb_season: Set(season.season_number),
flix_show: Set(flix.id),
@@ -306,6 +318,7 @@ pub async fn add(
for (season, episodes) in episodes {
for episode in episodes {
drop(
entity::info::episodes::ActiveModel {
show_id: Set(flix.id),
season_number: Set(season),
@@ -315,9 +328,10 @@ pub async fn add(
date: Set(episode.air_date),
}
.insert(txn)
.await?;
.await?,
);
entity::tmdb::episodes::ActiveModel {
_ = entity::tmdb::episodes::ActiveModel {
tmdb_show: Set(id),
tmdb_season: Set(season),
tmdb_episode: Set(episode.episode_number),
@@ -339,10 +353,11 @@ pub async fn add(
match result {
Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?,
};
println!("Created Show: {} ({})", title, year);
Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
return Err(err.into());
}
}
println!("Created Show: {title} ({year})");
Ok(())
}
@@ -366,11 +381,7 @@ pub async fn add(
.get_details(id, season_number, None)
.await
.with_context(|| {
format!(
"seasons().get_details({}, {})",
id.into_raw(),
season_number
)
format!("seasons().get_details({}, {season_number})", id.into_raw())
})?;
let mut episodes = Vec::new();
@@ -391,11 +402,9 @@ pub async fn add(
Ok(value) => value,
Err(err) => {
eprintln!(
"skipping episode ({}, {}, {}) - {}",
"skipping episode ({}, {}, {episode}) - {err}",
id.into_raw(),
season.season_number,
episode,
err
);
break;
}
@@ -406,6 +415,7 @@ pub async fn add(
let result: Result<(), TransactionError<DbErr>> = db
.transaction(|txn| {
Box::pin(async move {
drop(
entity::info::seasons::ActiveModel {
show_id: Set(show.flix_id),
season_number: Set(season_number),
@@ -414,9 +424,10 @@ pub async fn add(
date: Set(season.air_date),
}
.insert(txn)
.await?;
.await?,
);
entity::tmdb::seasons::ActiveModel {
_ = entity::tmdb::seasons::ActiveModel {
tmdb_show: Set(show.tmdb_id),
tmdb_season: Set(season_number),
flix_show: Set(show.flix_id),
@@ -427,6 +438,7 @@ pub async fn add(
.await?;
for episode in episodes {
drop(
entity::info::episodes::ActiveModel {
show_id: Set(show.flix_id),
season_number: Set(season_number),
@@ -436,9 +448,10 @@ pub async fn add(
date: Set(episode.air_date),
}
.insert(txn)
.await?;
.await?,
);
entity::tmdb::episodes::ActiveModel {
_ = entity::tmdb::episodes::ActiveModel {
tmdb_show: Set(show.tmdb_id),
tmdb_season: Set(season_number),
tmdb_episode: Set(episode.episode_number),
@@ -458,14 +471,14 @@ pub async fn add(
.await;
match result {
Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?,
};
Ok(()) => {}
Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
return Err(err.into());
}
}
println!(
"Created Season: {} S{}",
"Created Season: {} S{season_number}",
show.flix_id.into_raw(),
season_number
);
Ok(())
@@ -476,19 +489,10 @@ pub async fn add(
episode,
episodes,
} => {
let id = TmdbShowId::from_raw(id);
let season_number = season;
let Some(show) = entity::tmdb::shows::Entity::find_by_id(id).one(db).await? else {
bail!("show does not exists");
};
let Some(_) = entity::tmdb::seasons::Entity::find_by_id((id, season))
.one(db)
.await?
else {
bail!("season does not exists");
};
/// Fetch and store episode information.
///
/// # Errors
/// Forwards any database transaction failures or TMDB API failures.
async fn fetch_episode(
client: &Client,
db: &DatabaseConnection,
@@ -512,12 +516,13 @@ pub async fn add(
.get_details(id, season, episode_number, None)
.await
.with_context(|| {
format!("episodes().get_details({}, {})", id.into_raw(), season)
format!("episodes().get_details({}, {season})", id.into_raw())
})?;
let result: Result<(), TransactionError<DbErr>> = db
.transaction(|txn| {
Box::pin(async move {
drop(
entity::info::episodes::ActiveModel {
show_id: Set(flix_id),
season_number: Set(season),
@@ -527,9 +532,10 @@ pub async fn add(
date: Set(episode.air_date),
}
.insert(txn)
.await?;
.await?,
);
entity::tmdb::episodes::ActiveModel {
_ = entity::tmdb::episodes::ActiveModel {
tmdb_show: Set(tmdb_id),
tmdb_season: Set(season),
tmdb_episode: Set(episode_number),
@@ -548,20 +554,32 @@ pub async fn add(
.await;
match result {
Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?,
Err(TransactionError::Transaction(err)) => Err(err)?,
};
Ok(()) => {}
Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
return Err(err.into());
}
}
println!(
"Created Episode: {} S{}E{}",
"Created Episode: {} S{season}E{episode_number}",
flix_id.into_raw(),
season,
episode_number
);
Ok(())
}
let id = TmdbShowId::from_raw(id);
let season_number = season;
let Some(show) = entity::tmdb::shows::Entity::find_by_id(id).one(db).await? else {
bail!("show does not exists");
};
let Some(_) = entity::tmdb::seasons::Entity::find_by_id((id, season))
.one(db)
.await?
else {
bail!("season does not exists");
};
let flix_id = show.flix_id;
let tmdb_id = show.tmdb_id;
@@ -574,552 +592,3 @@ pub async fn add(
}
}
}
pub async fn update(client: Client, db: &DatabaseConnection, command: Command) -> Result<()> {
_ = client;
_ = db;
_ = command;
unimplemented!("updates")
// match command {
// Command::Collection { id } => {
// let id = TmdbCollectionId::from_raw(id);
// let collection = entity::tmdb::collections::Entity::find_by_id(id)
// .one(db)
// .await?;
// if collection.is_some() {
// bail!("collection already exists");
// }
// let collection = client
// .collections()
// .get_details(id, None)
// .await
// .with_context(|| format!("collections().get_details({})", id.into_raw()))?;
// let title = overrides.title.unwrap_or(collection.title);
// let sort_title = overrides
// .sort_title
// .unwrap_or_else(|| text::make_sortable_title(&title));
// let fs_slug = overrides
// .fs_slug
// .unwrap_or_else(|| text::make_fs_slug(&title));
// let web_slug = overrides
// .web_slug
// .unwrap_or_else(|| text::make_web_slug(&title));
// let result: Result<CollectionId, TransactionError<DbErr>> = db
// .transaction(|txn| {
// let title = title.clone();
// Box::pin(async move {
// let flix = entity::info::collections::ActiveModel {
// id: NotSet,
// title: Set(title),
// overview: Set(collection.overview),
// sort_title: Set(sort_title),
// fs_slug: Set(fs_slug),
// web_slug: Set(web_slug),
// }
// .insert(txn)
// .await?;
// entity::tmdb::collections::ActiveModel {
// tmdb_id: Set(id),
// flix_id: Set(flix.id),
// last_update: Set(Utc::now()),
// movie_count: Set(collection.movies.len().try_into().unwrap_or(0)),
// }
// .insert(txn)
// .await?;
// Ok(flix.id)
// })
// })
// .await;
// let flix_id = match result {
// Ok(id) => id,
// Err(TransactionError::Connection(err)) => Err(err)?,
// Err(TransactionError::Transaction(err)) => Err(err)?,
// };
// println!("Created Collection: {}", title, flix_id.into_raw());
// Ok(())
// }
// Command::Movie { id } => {
// let id = TmdbMovieId::from_raw(id);
// let movie = entity::tmdb::movies::Entity::find_by_id(id).one(db).await?;
// if movie.is_some() {
// bail!("movie already exists");
// }
// let movie = client
// .movies()
// .get_details(id, None)
// .await
// .with_context(|| format!("movies().get_details({})", id.into_raw()))?;
// let title = overrides.title.unwrap_or(movie.title);
// let year = movie.release_date.year();
// let sort_title = overrides
// .sort_title
// .unwrap_or_else(|| text::make_sortable_title(&title));
// let fs_slug = overrides
// .fs_slug
// .unwrap_or_else(|| text::make_fs_slug_year(&title, year));
// let web_slug = overrides
// .web_slug
// .unwrap_or_else(|| text::make_web_slug_year(&title, year));
// let result: Result<MovieId, TransactionError<DbErr>> = db
// .transaction(|txn| {
// let title = title.clone();
// Box::pin(async move {
// let flix = entity::info::movies::ActiveModel {
// id: NotSet,
// title: Set(title),
// tagline: Set(movie.tagline),
// overview: Set(movie.overview),
// date: Set(movie.release_date),
// sort_title: Set(sort_title),
// fs_slug: Set(fs_slug),
// web_slug: Set(web_slug),
// }
// .insert(txn)
// .await?;
// entity::tmdb::movies::ActiveModel {
// tmdb_id: Set(id),
// flix_id: Set(flix.id),
// last_update: Set(Utc::now()),
// runtime: Set(movie.runtime.into()),
// collection_id: Set(movie.collection.map(|c| c.id)),
// }
// .insert(txn)
// .await?;
// Ok(flix.id)
// })
// })
// .await;
// let flix_id = match result {
// Ok(id) => id,
// Err(TransactionError::Connection(err)) => Err(err)?,
// Err(TransactionError::Transaction(err)) => Err(err)?,
// };
// println!(
// "Created Movie: {} ({})",
// title,
// year,
// flix_id.into_raw(),
// );
// Ok(())
// }
// Command::Show { id } => {
// let id = TmdbShowId::from_raw(id);
// let show = entity::tmdb::shows::Entity::find_by_id(id).one(db).await?;
// if show.is_some() {
// bail!("show already exists");
// }
// let show = client
// .shows()
// .get_details(id, None)
// .await
// .with_context(|| format!("shows().get_details({})", id.into_raw()))?;
// let mut seasons = Vec::new();
// let mut episodes = HashMap::new();
// for season in 1..=show.number_of_seasons {
// let season = SeasonNumber::new(season);
// let season = match client
// .seasons()
// .get_details(id, season, None)
// .await
// .with_context(|| {
// format!("seasons().get_details({}, {})", id.into_raw(), season)
// }) {
// Ok(season) => season,
// Err(err) => {
// eprintln!("{err:?}");
// continue;
// }
// };
// if season.air_date > Utc::now().naive_utc().date() {
// eprintln!(
// "skipping season ({}, {})",
// id.into_raw(),
// season.season_number
// );
// break;
// }
// let Ok(number_of_episodes) = u32::try_from(season.episodes.len()) else {
// bail!(
// "could not convert {} to an EpisodeNumber",
// season.episodes.len()
// )
// };
// let mut season_episodes = Vec::new();
// for episode in 1..=number_of_episodes {
// let episode = EpisodeNumber::new(episode);
// let Ok(episode) = client
// .episodes()
// .get_details(id, season.season_number, episode, None)
// .await
// else {
// eprintln!(
// "skipping episode ({}, {}, {})",
// id.into_raw(),
// season.season_number,
// episode
// );
// break;
// };
// season_episodes.push(episode);
// }
// episodes.insert(season.season_number, season_episodes);
// seasons.push(season);
// }
// let title = overrides.title.unwrap_or(show.title);
// let year = show.first_air_date.year();
// let sort_title = overrides
// .sort_title
// .unwrap_or_else(|| text::make_sortable_title(&title));
// let fs_slug = overrides
// .fs_slug
// .unwrap_or_else(|| text::make_fs_slug_year(&title, year));
// let web_slug = overrides
// .web_slug
// .unwrap_or_else(|| text::make_web_slug_year(&title, year));
// let result: Result<ShowId, TransactionError<DbErr>> = db
// .transaction(|txn| {
// let title = title.clone();
// Box::pin(async move {
// let flix = entity::info::shows::ActiveModel {
// id: NotSet,
// title: Set(title),
// tagline: Set(show.tagline),
// overview: Set(show.overview),
// date: Set(show.first_air_date),
// sort_title: Set(sort_title),
// fs_slug: Set(fs_slug),
// web_slug: Set(web_slug),
// }
// .insert(txn)
// .await?;
// entity::tmdb::shows::ActiveModel {
// tmdb_id: Set(id),
// flix_id: Set(flix.id),
// last_update: Set(Utc::now()),
// number_of_seasons: Set(show.number_of_seasons),
// }
// .insert(txn)
// .await?;
// for season in seasons {
// entity::info::seasons::ActiveModel {
// show_id: Set(flix.id),
// season_number: Set(season.season_number),
// title: Set(season.title),
// overview: Set(season.overview),
// date: Set(season.air_date),
// }
// .insert(txn)
// .await?;
// entity::tmdb::seasons::ActiveModel {
// tmdb_show: Set(id),
// tmdb_season: Set(season.season_number),
// flix_show: Set(flix.id),
// flix_season: Set(season.season_number),
// last_update: Set(Utc::now()),
// }
// .insert(txn)
// .await?;
// }
// for (season, episodes) in episodes {
// for episode in episodes {
// entity::info::episodes::ActiveModel {
// show_id: Set(flix.id),
// season_number: Set(season),
// episode_number: Set(episode.episode_number),
// title: Set(episode.title),
// overview: Set(episode.overview),
// date: Set(episode.air_date),
// }
// .insert(txn)
// .await?;
// entity::tmdb::episodes::ActiveModel {
// tmdb_show: Set(id),
// tmdb_season: Set(season),
// tmdb_episode: Set(episode.episode_number),
// flix_show: Set(flix.id),
// flix_season: Set(season),
// flix_episode: Set(episode.episode_number),
// last_update: Set(Utc::now()),
// runtime: Set(episode.runtime.into()),
// }
// .insert(txn)
// .await?;
// }
// }
// Ok(flix.id)
// })
// })
// .await;
// let flix_id = match result {
// Ok(id) => id,
// Err(TransactionError::Connection(err)) => Err(err)?,
// Err(TransactionError::Transaction(err)) => Err(err)?,
// };
// println!(
// "Created Show: {} ({})",
// title,
// year,
// flix_id.into_raw()
// );
// Ok(())
// }
// Command::Season { id, season } => {
// let id = TmdbShowId::from_raw(id);
// let season_number = season;
// let Some(show) = entity::tmdb::shows::Entity::find_by_id(id).one(db).await? else {
// bail!("show does not exists");
// };
// let season = entity::tmdb::seasons::Entity::find_by_id((id, season))
// .one(db)
// .await?;
// if season.is_some() {
// bail!("season already exists");
// }
// let season = client
// .seasons()
// .get_details(id, season_number, None)
// .await
// .with_context(|| {
// format!(
// "seasons().get_details({}, {})",
// id.into_raw(),
// season_number
// )
// })?;
// let mut episodes = Vec::new();
// let Ok(number_of_episodes) = u32::try_from(season.episodes.len()) else {
// bail!(
// "could not convert {} to an EpisodeNumber",
// season.episodes.len()
// )
// };
// for episode in 1..=number_of_episodes {
// let episode = EpisodeNumber::new(episode);
// let Ok(episode) = client
// .episodes()
// .get_details(id, season.season_number, episode, None)
// .await
// else {
// eprintln!(
// "skipping episode ({}, {}, {})",
// id.into_raw(),
// season.season_number,
// episode
// );
// break;
// };
// episodes.push(episode);
// }
// let result: Result<(), TransactionError<DbErr>> = db
// .transaction(|txn| {
// Box::pin(async move {
// entity::info::seasons::ActiveModel {
// show_id: Set(show.flix_id),
// season_number: Set(season_number),
// title: Set(season.title),
// overview: Set(season.overview),
// date: Set(season.air_date),
// }
// .insert(txn)
// .await?;
// entity::tmdb::seasons::ActiveModel {
// tmdb_show: Set(show.tmdb_id),
// tmdb_season: Set(season_number),
// flix_show: Set(show.flix_id),
// flix_season: Set(season_number),
// last_update: Set(Utc::now()),
// }
// .insert(txn)
// .await?;
// for episode in episodes {
// entity::info::episodes::ActiveModel {
// show_id: Set(show.flix_id),
// season_number: Set(season_number),
// episode_number: Set(episode.episode_number),
// title: Set(episode.title),
// overview: Set(episode.overview),
// date: Set(episode.air_date),
// }
// .insert(txn)
// .await?;
// entity::tmdb::episodes::ActiveModel {
// tmdb_show: Set(show.tmdb_id),
// tmdb_season: Set(season_number),
// tmdb_episode: Set(episode.episode_number),
// flix_show: Set(show.flix_id),
// flix_season: Set(season_number),
// flix_episode: Set(episode.episode_number),
// last_update: Set(Utc::now()),
// runtime: Set(episode.runtime.into()),
// }
// .insert(txn)
// .await?;
// }
// Ok(())
// })
// })
// .await;
// match result {
// Ok(_) => (),
// Err(TransactionError::Connection(err)) => Err(err)?,
// Err(TransactionError::Transaction(err)) => Err(err)?,
// };
// println!(
// "Created Season: {} S{}",
// show.flix_id.into_raw(),
// season_number
// );
// Ok(())
// }
// Command::Episode {
// id,
// season,
// episode,
// episodes,
// } => {
// let id = TmdbShowId::from_raw(id);
// let season_number = season;
// let Some(show) = entity::tmdb::shows::Entity::find_by_id(id).one(db).await? else {
// bail!("show does not exists");
// };
// let Some(_) = entity::tmdb::seasons::Entity::find_by_id((id, season))
// .one(db)
// .await?
// else {
// bail!("season does not exists");
// };
// async fn fetch_episode(
// client: &Client,
// db: &DatabaseConnection,
// flix_id: ShowId,
// tmdb_id: TmdbShowId,
// id: TmdbShowId,
// season: SeasonNumber,
// episode: EpisodeNumber,
// ) -> Result<()> {
// let episode_number = episode;
// let episode = entity::tmdb::episodes::Entity::find_by_id((id, season, episode))
// .one(db)
// .await?;
// if episode.is_some() {
// bail!("episode already exists");
// }
// let episode = client
// .episodes()
// .get_details(id, season, episode_number, None)
// .await
// .with_context(|| {
// format!("episodes().get_details({}, {})", id.into_raw(), season)
// })?;
// let result: Result<(), TransactionError<DbErr>> = db
// .transaction(|txn| {
// Box::pin(async move {
// entity::info::episodes::ActiveModel {
// show_id: Set(flix_id),
// season_number: Set(season),
// episode_number: Set(episode_number),
// title: Set(episode.title),
// overview: Set(episode.overview),
// date: Set(episode.air_date),
// }
// .insert(txn)
// .await?;
// entity::tmdb::episodes::ActiveModel {
// tmdb_show: Set(tmdb_id),
// tmdb_season: Set(season),
// tmdb_episode: Set(episode_number),
// flix_show: Set(flix_id),
// flix_season: Set(season),
// flix_episode: Set(episode_number),
// last_update: Set(Utc::now()),
// runtime: Set(episode.runtime.into()),
// }
// .insert(txn)
// .await?;
// Ok(())
// })
// })
// .await;
// match result {
// Ok(_) => (),
// Err(TransactionError::Connection(err)) => Err(err)?,
// Err(TransactionError::Transaction(err)) => Err(err)?,
// };
// println!(
// "Created Episode: {} S{}E{}",
// flix_id.into_raw(),
// season,
// episode_number
// );
// Ok(())
// }
// let flix_id = show.flix_id;
// let tmdb_id = show.tmdb_id;
// fetch_episode(&client, db, flix_id, tmdb_id, id, season_number, episode).await?;
// for episode in episodes {
// fetch_episode(&client, db, flix_id, tmdb_id, id, season_number, episode).await?;
// }
// Ok(())
// }
// }
}
+8 -6
View File
@@ -1,14 +1,16 @@
[package]
name = "flix-db"
version = "0.0.20"
license-file.workspace = true
version = "0.1.0"
description = "Types for storing persistent data about media"
repository = "https://github.com/QuantumShade/flix"
categories = []
repository = "https://git.skrundz.dev/quantumshade/flix"
keywords = ["flix"]
categories = ["multimedia"]
include = ["/src"]
publish = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs]
all-features = true
@@ -29,7 +31,7 @@ flix-tmdb = { workspace = true, features = ["sea-orm"], optional = true }
[dev-dependencies]
sea-orm-migration = { workspace = true, features = ["runtime-tokio-rustls"] }
tokio = { version = "^1", default-features = false, features = [
tokio = { workspace = true, features = [
"macros",
"rt",
] }
+15 -3
View File
@@ -1,15 +1,26 @@
//! Types and functions related to [DatabaseConnection]s
//! Types and functions related to [`DatabaseConnection`]s.
use sea_orm::{DatabaseConnection, DbErr};
use sea_orm_migration::MigratorTrait as _;
/// A newtype wrapping a [DatabaseConnection]
/// A newtype wrapping a [`DatabaseConnection`].
#[derive(Debug)]
pub struct Connection(DatabaseConnection);
impl Connection {
/// Helper function for applying database migrations while wrapping a
/// [DatabaseConnection] in a newtype
/// [`DatabaseConnection`] in a newtype.
///
/// # Errors
/// Fails if connecting or applying migrations fail.
#[inline]
pub async fn try_from(db: DatabaseConnection) -> Result<Self, DbErr> {
// The migrations only create views which store no data, so it is
// important to down before up since modifications are impossible.
//
// Syncing the schema registry allows all real tables to be upgraded.
// It is important to sync twice to ensure internal consistency so that
// the views can be recreated on the latest schema.
crate::migration::Migrator::down(&db, None).await?;
db.get_schema_registry("flix_db::*").sync(&db).await?;
db.get_schema_registry("flix_db::*").sync(&db).await?;
@@ -19,6 +30,7 @@ impl Connection {
}
impl AsRef<DatabaseConnection> for Connection {
#[inline]
fn as_ref(&self) -> &DatabaseConnection {
&self.0
}
+128 -90
View File
@@ -1,6 +1,6 @@
//! This module contains entities for storing media file information
//! This module contains entities for storing media file information.
/// Library entity
/// Library entity.
pub mod libraries {
use flix_model::id::LibraryId;
@@ -10,42 +10,43 @@ pub mod libraries {
use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*;
/// The database representation of a library media folder
/// The database representation of a library media folder.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_libraries")]
pub struct Model {
/// The library's ID
/// The library's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub id: LibraryId,
/// The library's directory
/// The library's directory.
pub directory: PathBytes,
/// The library's last scan data
/// The library's last scan data.
pub last_scan_date: Option<DateTime<Utc>>,
/// The library's last scan duration
/// The library's last scan duration.
pub last_scan_duration: Option<Seconds>,
/// Collections that are part of this library
/// Collections that are part of this library.
#[sea_orm(has_many)]
pub collections: HasMany<super::collections::Entity>,
/// Movies that are part of this library
/// Movies that are part of this library.
#[sea_orm(has_many)]
pub movies: HasMany<super::movies::Entity>,
/// Shows that are part of this library
/// Shows that are part of this library.
#[sea_orm(has_many)]
pub shows: HasMany<super::shows::Entity>,
/// Seasons that are part of this library
/// Seasons that are part of this library.
#[sea_orm(has_many)]
pub seasons: HasMany<super::seasons::Entity>,
/// Episodes that are part of this library
/// Episodes that are part of this library.
#[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Collection entity
/// Collection entity.
pub mod collections {
use flix_model::id::{CollectionId, LibraryId};
@@ -55,25 +56,25 @@ pub mod collections {
use crate::entity;
/// The database representation of a collection media folder
/// The database representation of a collection media folder.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_collections")]
pub struct Model {
/// The collection's ID
/// The collection's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub id: CollectionId,
/// The collection's parent
/// The collection's parent.
#[sea_orm(indexed)]
pub parent_id: Option<CollectionId>,
/// The collection's library ID
/// The collection's library ID.
pub library_id: LibraryId,
/// The collection's directory
/// The collection's directory.
pub directory: PathBytes,
/// The collection's poster path
/// The collection's poster path.
pub relative_poster_path: Option<String>,
/// This collection's parent
/// This collection's parent.
#[sea_orm(
self_ref,
relation_enum = "Parent",
@@ -83,7 +84,7 @@ pub mod collections {
on_delete = "Cascade"
)]
pub parent: HasOne<Entity>,
/// The library this collection belongs to
/// The library this collection belongs to.
#[sea_orm(
belongs_to,
from = "library_id",
@@ -92,7 +93,7 @@ pub mod collections {
on_delete = "Cascade"
)]
pub library: HasOne<super::libraries::Entity>,
/// The info for this collection
/// The info for this collection.
#[sea_orm(
belongs_to,
relation_enum = "Info",
@@ -103,15 +104,16 @@ pub mod collections {
)]
pub info: HasOne<entity::info::collections::Entity>,
/// The watched info for this collection
/// The watched info for this collection.
#[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")]
pub watched: HasMany<entity::watched::collections::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Movie entity
/// Movie entity.
pub mod movies {
use flix_model::id::{CollectionId, LibraryId, MovieId};
@@ -121,27 +123,27 @@ pub mod movies {
use crate::entity;
/// The database representation of a movie media folder
/// The database representation of a movie media folder.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_movies")]
pub struct Model {
/// The movie's ID
/// The movie's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub id: MovieId,
/// The movie's parent
/// The movie's parent.
#[sea_orm(indexed)]
pub parent_id: Option<CollectionId>,
/// The movie's library
/// The movie's library.
pub library_id: LibraryId,
/// The movie's directory
/// The movie's directory.
pub directory: PathBytes,
/// The movie's media path
/// The movie's media path.
pub relative_media_path: String,
/// The movie's poster path
/// The movie's poster path.
pub relative_poster_path: Option<String>,
/// This movie's parent
/// This movie's parent.
#[sea_orm(
belongs_to,
from = "parent_id",
@@ -150,7 +152,7 @@ pub mod movies {
on_delete = "Cascade"
)]
pub parent: HasOne<super::collections::Entity>,
/// The library this movie belongs to
/// The library this movie belongs to.
#[sea_orm(
belongs_to,
from = "library_id",
@@ -159,7 +161,7 @@ pub mod movies {
on_delete = "Cascade"
)]
pub library: HasOne<super::libraries::Entity>,
/// The info for this movie
/// The info for this movie.
#[sea_orm(
belongs_to,
relation_enum = "Info",
@@ -170,15 +172,16 @@ pub mod movies {
)]
pub info: HasOne<entity::info::movies::Entity>,
/// The watched info for this movie
/// The watched info for this movie.
#[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")]
pub watched: HasMany<entity::watched::movies::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Show entity
/// Show entity.
pub mod shows {
use flix_model::id::{CollectionId, LibraryId, ShowId};
@@ -188,25 +191,25 @@ pub mod shows {
use crate::entity;
/// The database representation of a show media folder
/// The database representation of a show media folder.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_shows")]
pub struct Model {
/// The show's ID
/// The show's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub id: ShowId,
/// The show's parent
/// The show's parent.
#[sea_orm(indexed)]
pub parent_id: Option<CollectionId>,
/// The show's library
/// The show's library.
pub library_id: LibraryId,
/// The show's directory
/// The show's directory.
pub directory: PathBytes,
/// The show's poster path
/// The show's poster path.
pub relative_poster_path: Option<String>,
/// This show's parent
/// This show's parent.
#[sea_orm(
belongs_to,
from = "parent_id",
@@ -215,7 +218,7 @@ pub mod shows {
on_delete = "Cascade"
)]
pub parent: HasOne<super::collections::Entity>,
/// The library this show belongs to
/// The library this show belongs to.
#[sea_orm(
belongs_to,
from = "library_id",
@@ -224,7 +227,7 @@ pub mod shows {
on_delete = "Cascade"
)]
pub library: HasOne<super::libraries::Entity>,
/// The info for this show
/// The info for this show.
#[sea_orm(
belongs_to,
relation_enum = "Info",
@@ -235,21 +238,22 @@ pub mod shows {
)]
pub info: HasOne<entity::info::shows::Entity>,
/// Seasons that are part of this show
/// Seasons that are part of this show.
#[sea_orm(has_many)]
pub seasons: HasMany<super::seasons::Entity>,
/// Episodes that are part of this show
/// Episodes that are part of this show.
#[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>,
/// The watched info for this show
/// The watched info for this show.
#[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")]
pub watched: HasMany<entity::watched::shows::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Season entity
/// Season entity.
pub mod seasons {
use flix_model::id::{LibraryId, ShowId};
use flix_model::numbers::SeasonNumber;
@@ -260,25 +264,25 @@ pub mod seasons {
use crate::entity;
/// The database representation of a season media folder
/// The database representation of a season media folder.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_seasons")]
pub struct Model {
/// The season's show's ID
/// The season's show's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub show_id: ShowId,
/// The season's number
/// The season's number.
#[sea_orm(primary_key, auto_increment = false)]
pub season_number: SeasonNumber,
/// The season's library
/// The season's library.
pub library_id: LibraryId,
/// The season's directory
/// The season's directory.
pub directory: PathBytes,
/// The season's poster path
/// The season's poster path.
pub relative_poster_path: Option<String>,
/// This season's show
/// This season's show.
#[sea_orm(
belongs_to,
from = "show_id",
@@ -287,7 +291,7 @@ pub mod seasons {
on_delete = "Cascade"
)]
pub show: HasOne<super::shows::Entity>,
/// The library this season belongs to
/// The library this season belongs to.
#[sea_orm(
belongs_to,
from = "library_id",
@@ -296,7 +300,7 @@ pub mod seasons {
on_delete = "Cascade"
)]
pub library: HasOne<super::libraries::Entity>,
/// The info for this season
/// The info for this season.
#[sea_orm(
belongs_to,
relation_enum = "Info",
@@ -307,10 +311,10 @@ pub mod seasons {
)]
pub info: HasOne<entity::info::seasons::Entity>,
/// Episodes that are part of this show
/// Episodes that are part of this show.
#[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>,
/// The watched info for this season
/// The watched info for this season.
#[sea_orm(
has_many,
relation_enum = "Watched",
@@ -320,10 +324,11 @@ pub mod seasons {
pub watched: HasMany<entity::watched::seasons::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Episode entity
/// Episode entity.
pub mod episodes {
use flix_model::id::{LibraryId, ShowId};
use flix_model::numbers::{EpisodeNumber, SeasonNumber};
@@ -334,32 +339,32 @@ pub mod episodes {
use crate::entity;
/// The database representation of a episode media folder
/// The database representation of a episode media folder.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_episodes")]
pub struct Model {
/// The episode's show's ID
/// The episode's show's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub show_id: ShowId,
/// The episode's season's number
/// The episode's season's number.
#[sea_orm(primary_key, auto_increment = false)]
pub season_number: SeasonNumber,
/// The episode's number
/// The episode's number.
#[sea_orm(primary_key, auto_increment = false)]
pub episode_number: EpisodeNumber,
/// The number of additional contained episodes
/// The number of additional contained episodes.
pub count: u8,
/// The episode's library
/// The episode's library.
pub library_id: LibraryId,
/// The episode's directory
/// The episode's directory.
pub directory: PathBytes,
/// The episode's media path
/// The episode's media path.
pub relative_media_path: String,
/// The episode's poster path
/// The episode's poster path.
pub relative_poster_path: Option<String>,
/// This episode's show
/// This episode's show.
#[sea_orm(
belongs_to,
from = "show_id",
@@ -368,7 +373,7 @@ pub mod episodes {
on_delete = "Cascade"
)]
pub show: HasOne<super::shows::Entity>,
/// This episode's season
/// This episode's season.
#[sea_orm(
belongs_to,
from = "(show_id, season_number)",
@@ -377,7 +382,7 @@ pub mod episodes {
on_delete = "Cascade"
)]
pub season: HasOne<super::seasons::Entity>,
/// The library this episode belongs to
/// The library this episode belongs to.
#[sea_orm(
belongs_to,
from = "library_id",
@@ -386,7 +391,7 @@ pub mod episodes {
on_delete = "Cascade"
)]
pub library: HasOne<super::libraries::Entity>,
/// The info for this episode
/// The info for this episode.
#[sea_orm(
belongs_to,
relation_enum = "Info",
@@ -397,7 +402,7 @@ pub mod episodes {
)]
pub info: HasOne<entity::info::episodes::Entity>,
/// The watched info for this episode
/// The watched info for this episode.
#[sea_orm(
has_many,
relation_enum = "Watched",
@@ -407,14 +412,16 @@ pub mod episodes {
pub watched: HasMany<entity::watched::episodes::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Macros for creating content entities
/// Macros for creating content entities.
#[cfg(test)]
pub mod test {
macro_rules! make_content_library {
($db:expr, $id:expr) => {
drop(
$crate::entity::content::libraries::ActiveModel {
id: Set(::flix_model::id::LibraryId::from_raw($id)),
directory: Set(::std::path::PathBuf::new().into()),
@@ -423,7 +430,8 @@ pub mod test {
}
.insert($db)
.await
.expect("insert");
.expect("insert"),
);
};
}
pub(crate) use make_content_library;
@@ -431,6 +439,7 @@ pub mod test {
macro_rules! make_content_collection {
($db:expr, $lid:expr, $id:expr, $pid:expr) => {
$crate::entity::info::test::make_info_collection!($db, $id);
drop(
$crate::entity::content::collections::ActiveModel {
id: Set(::flix_model::id::CollectionId::from_raw($id)),
parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
@@ -440,7 +449,8 @@ pub mod test {
}
.insert($db)
.await
.expect("insert");
.expect("insert"),
);
};
}
pub(crate) use make_content_collection;
@@ -448,17 +458,19 @@ pub mod test {
macro_rules! make_content_movie {
($db:expr, $lid:expr, $id:expr, $pid:expr) => {
$crate::entity::info::test::make_info_movie!($db, $id);
drop(
$crate::entity::content::movies::ActiveModel {
id: Set(::flix_model::id::MovieId::from_raw($id)),
parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
directory: Set(::std::path::PathBuf::new().into()),
relative_media_path: Set(::std::string::String::new()),
relative_media_path: Set(::alloc::string::String::new()),
relative_poster_path: Set(::core::option::Option::None),
}
.insert($db)
.await
.expect("insert");
.expect("insert"),
);
};
}
pub(crate) use make_content_movie;
@@ -466,6 +478,7 @@ pub mod test {
macro_rules! make_content_show {
($db:expr, $lid:expr, $id:expr, $pid:expr) => {
$crate::entity::info::test::make_info_show!($db, $id);
drop(
$crate::entity::content::shows::ActiveModel {
id: Set(::flix_model::id::ShowId::from_raw($id)),
parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
@@ -475,7 +488,8 @@ pub mod test {
}
.insert($db)
.await
.expect("insert");
.expect("insert"),
);
};
}
pub(crate) use make_content_show;
@@ -483,6 +497,7 @@ pub mod test {
macro_rules! make_content_season {
($db:expr, $lid:expr, $show:expr, $season:expr) => {
$crate::entity::info::test::make_info_season!($db, $show, $season);
drop(
$crate::entity::content::seasons::ActiveModel {
show_id: Set(::flix_model::id::ShowId::from_raw($show)),
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
@@ -492,7 +507,8 @@ pub mod test {
}
.insert($db)
.await
.expect("insert");
.expect("insert"),
);
};
}
pub(crate) use make_content_season;
@@ -506,6 +522,7 @@ pub mod test {
};
(@make, $db:expr, $lid:expr, $show:expr, $season:expr, $episode:expr, $count:literal) => {
$crate::entity::info::test::make_info_episode!($db, $show, $season, $episode);
drop(
$crate::entity::content::episodes::ActiveModel {
show_id: Set(::flix_model::id::ShowId::from_raw($show)),
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
@@ -513,12 +530,13 @@ pub mod test {
count: Set($count),
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
directory: Set(::std::path::PathBuf::new().into()),
relative_media_path: Set(::std::string::String::new()),
relative_media_path: Set(::alloc::string::String::new()),
relative_poster_path: Set(::core::option::Option::None),
}
.insert($db)
.await
.expect("insert");
.expect("insert"),
);
};
}
pub(crate) use make_content_episode;
@@ -550,7 +568,10 @@ mod tests {
use super::super::tests::get_error_kind;
use super::super::tests::{noneable, notsettable};
#[cfg(not(miri))]
#[tokio::test]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn use_test_macros() {
let db = new_initialized_memory_db().await;
@@ -562,8 +583,10 @@ mod tests {
make_content_episode!(&db, 1, 1, 1, 1);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_libraries() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn round_trip_libraries() {
let db = new_initialized_memory_db().await;
macro_rules! assert_library {
@@ -601,8 +624,11 @@ mod tests {
assert_library!(&db, 6, Success; last_scan_duration);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_collections() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn round_trip_collections() {
let db = new_initialized_memory_db().await;
macro_rules! assert_collection {
@@ -656,8 +682,11 @@ mod tests {
assert_collection!(&db, 7, None, 1, Success; relative_poster_path);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_movies() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn round_trip_movies() {
let db = new_initialized_memory_db().await;
macro_rules! assert_movie {
@@ -718,8 +747,11 @@ mod tests {
assert_movie!(&db, 8, None, 1, Success; relative_poster_path);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_shows() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn round_trip_shows() {
let db = new_initialized_memory_db().await;
macro_rules! assert_show {
@@ -776,8 +808,11 @@ mod tests {
assert_show!(&db, 7, None, 1, Success; relative_poster_path);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_seasons() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn round_trip_seasons() {
let db = new_initialized_memory_db().await;
macro_rules! assert_season {
@@ -828,8 +863,11 @@ mod tests {
assert_season!(&db, 1, 7, 1, Success; relative_poster_path);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_episodes() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn round_trip_episodes() {
let db = new_initialized_memory_db().await;
macro_rules! assert_episode {
+111 -81
View File
@@ -1,7 +1,7 @@
//! This module contains entities for storing media information such as
//! titles and overviews
//! titles and overviews.
/// Collection entity
/// Collection entity.
pub mod collections {
use flix_model::id::CollectionId;
@@ -9,38 +9,39 @@ pub mod collections {
use crate::entity;
/// The database representation of a flix collection
/// The database representation of a flix collection.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_info_collections")]
pub struct Model {
/// The collection's ID
/// The collection's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub id: CollectionId,
/// The collection's title
/// The collection's title.
pub title: String,
/// The collection's overview
/// The collection's overview.
pub overview: String,
/// The sortable title
/// The sortable title.
#[sea_orm(indexed)]
pub sort_title: String,
/// The filesystem-safe slug
/// The filesystem-safe slug.
#[sea_orm(indexed, unique)]
pub fs_slug: String,
/// The url-safe slug
/// The url-safe slug.
#[sea_orm(indexed, unique)]
pub web_slug: String,
/// Potential content for this collection
/// Potential content for this collection.
#[sea_orm(has_one)]
pub content: HasOne<entity::content::collections::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Movie entity
/// Movie entity.
pub mod movies {
use flix_model::id::MovieId;
@@ -49,43 +50,44 @@ pub mod movies {
use crate::entity;
/// The database representation of a flix movie
/// The database representation of a flix movie.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_info_movies")]
pub struct Model {
/// The movie's ID
/// The movie's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub id: MovieId,
/// The movie's title
/// The movie's title.
pub title: String,
/// The movie's tagline
/// The movie's tagline.
pub tagline: String,
/// The movie's overview
/// The movie's overview.
pub overview: String,
/// The movie's release date
/// The movie's release date.
#[sea_orm(indexed)]
pub date: NaiveDate,
/// The sortable title
/// The sortable title.
#[sea_orm(indexed)]
pub sort_title: String,
/// The filesystem-safe slug
/// The filesystem-safe slug.
#[sea_orm(indexed, unique)]
pub fs_slug: String,
/// The url-safe slug
/// The url-safe slug.
#[sea_orm(indexed, unique)]
pub web_slug: String,
/// Potential content for this movie
/// Potential content for this movie.
#[sea_orm(has_one)]
pub content: HasOne<entity::content::movies::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Show entity
/// Show entity.
pub mod shows {
use flix_model::id::ShowId;
@@ -94,50 +96,51 @@ pub mod shows {
use crate::entity;
/// The database representation of a flix show
/// The database representation of a flix show.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_info_shows")]
pub struct Model {
/// The show's ID
/// The show's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub id: ShowId,
/// The show's title
/// The show's title.
pub title: String,
/// The show's tagline
/// The show's tagline.
pub tagline: String,
/// The show's overview
/// The show's overview.
pub overview: String,
/// The show's air date
/// The show's air date.
#[sea_orm(indexed)]
pub date: NaiveDate,
/// The sortable title
/// The sortable title.
#[sea_orm(indexed)]
pub sort_title: String,
/// The filesystem-safe slug
/// The filesystem-safe slug.
#[sea_orm(indexed, unique)]
pub fs_slug: String,
/// The url-safe slug
/// The url-safe slug.
#[sea_orm(indexed, unique)]
pub web_slug: String,
/// Seasons that are part of this show
/// Seasons that are part of this show.
#[sea_orm(has_many)]
pub seasons: HasMany<super::seasons::Entity>,
/// Episodes that are part of this show
/// Episodes that are part of this show.
#[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>,
/// Potential content for this show
/// Potential content for this show.
#[sea_orm(has_one)]
pub content: HasOne<entity::content::shows::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Season entity
/// Season entity.
pub mod seasons {
use flix_model::id::ShowId;
use flix_model::numbers::SeasonNumber;
@@ -147,26 +150,26 @@ pub mod seasons {
use crate::entity;
/// The database representation of a flix season
/// The database representation of a flix season.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_info_seasons")]
pub struct Model {
/// The season's show's ID
/// The season's show's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub show_id: ShowId,
/// The season's number
/// The season's number.
#[sea_orm(primary_key, auto_increment = false)]
pub season_number: SeasonNumber,
/// The season's title
/// The season's title.
pub title: String,
/// The season's overview
/// The season's overview.
pub overview: String,
/// The season's air date
/// The season's air date.
#[sea_orm(indexed)]
pub date: NaiveDate,
/// The show this season belongs to
/// The show this season belongs to.
#[sea_orm(
belongs_to,
from = "show_id",
@@ -175,19 +178,20 @@ pub mod seasons {
on_delete = "Cascade"
)]
pub show: HasOne<super::shows::Entity>,
/// Episodes that are part of this season
/// Episodes that are part of this season.
#[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>,
/// Potential content for this season
/// Potential content for this season.
#[sea_orm(has_one)]
pub content: HasOne<entity::content::seasons::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Episode entity
/// Episode entity.
pub mod episodes {
use flix_model::id::ShowId;
use flix_model::numbers::{EpisodeNumber, SeasonNumber};
@@ -197,29 +201,29 @@ pub mod episodes {
use crate::entity;
/// The database representation of a flix episode
/// The database representation of a flix episode.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_info_episodes")]
pub struct Model {
/// The episode's show's ID
/// The episode's show's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub show_id: ShowId,
/// The episode's season's number
/// The episode's season's number.
#[sea_orm(primary_key, auto_increment = false)]
pub season_number: SeasonNumber,
/// The episode's number
/// The episode's number.
#[sea_orm(primary_key, auto_increment = false)]
pub episode_number: EpisodeNumber,
/// The episode's title
/// The episode's title.
pub title: String,
/// The episode's overview
/// The episode's overview.
pub overview: String,
/// The episode's air date
/// The episode's air date.
#[sea_orm(indexed)]
pub date: NaiveDate,
/// The show this episode belongs to
/// The show this episode belongs to.
#[sea_orm(
belongs_to,
from = "show_id",
@@ -228,7 +232,7 @@ pub mod episodes {
on_delete = "Cascade"
)]
pub show: HasOne<super::shows::Entity>,
/// The season this episode belongs to
/// The season this episode belongs to.
#[sea_orm(
belongs_to,
from = "(show_id, season_number)",
@@ -238,101 +242,112 @@ pub mod episodes {
)]
pub season: HasOne<super::seasons::Entity>,
/// Potential content for this episode
/// Potential content for this episode.
#[sea_orm(has_one)]
pub content: HasOne<entity::content::episodes::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Macros for creating info entities
/// Macros for creating info entities.
#[cfg(test)]
pub mod test {
macro_rules! make_info_collection {
($db:expr, $id:expr) => {
drop(
$crate::entity::info::collections::ActiveModel {
id: Set(::flix_model::id::CollectionId::from_raw($id)),
title: Set(::std::string::String::new()),
overview: Set(::std::string::String::new()),
sort_title: Set(::std::string::String::new()),
title: Set(::alloc::string::String::new()),
overview: Set(::alloc::string::String::new()),
sort_title: Set(::alloc::string::String::new()),
fs_slug: Set(format!("C FS {}", $id)),
web_slug: Set(format!("C Web {}", $id)),
}
.insert($db)
.await
.expect("insert");
.expect("insert"),
);
};
}
pub(crate) use make_info_collection;
macro_rules! make_info_movie {
($db:expr, $id:expr) => {
drop(
$crate::entity::info::movies::ActiveModel {
id: Set(::flix_model::id::MovieId::from_raw($id)),
title: Set(::std::string::String::new()),
tagline: Set(::std::string::String::new()),
overview: Set(::std::string::String::new()),
title: Set(::alloc::string::String::new()),
tagline: Set(::alloc::string::String::new()),
overview: Set(::alloc::string::String::new()),
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
sort_title: Set(::std::string::String::new()),
sort_title: Set(::alloc::string::String::new()),
fs_slug: Set(format!("M FS {}", $id)),
web_slug: Set(format!("M Web {}", $id)),
}
.insert($db)
.await
.expect("insert");
.expect("insert"),
);
};
}
pub(crate) use make_info_movie;
macro_rules! make_info_show {
($db:expr, $id:expr) => {
drop(
$crate::entity::info::shows::ActiveModel {
id: Set(::flix_model::id::ShowId::from_raw($id)),
title: Set(::std::string::String::new()),
tagline: Set(::std::string::String::new()),
overview: Set(::std::string::String::new()),
title: Set(::alloc::string::String::new()),
tagline: Set(::alloc::string::String::new()),
overview: Set(::alloc::string::String::new()),
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
sort_title: Set(::std::string::String::new()),
sort_title: Set(::alloc::string::String::new()),
fs_slug: Set(format!("S FS {}", $id)),
web_slug: Set(format!("S Web {}", $id)),
}
.insert($db)
.await
.expect("insert");
.expect("insert"),
);
};
}
pub(crate) use make_info_show;
macro_rules! make_info_season {
($db:expr, $show:expr, $season:expr) => {
drop(
$crate::entity::info::seasons::ActiveModel {
show_id: Set(::flix_model::id::ShowId::from_raw($show)),
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
title: Set(::std::string::String::new()),
overview: Set(::std::string::String::new()),
title: Set(::alloc::string::String::new()),
overview: Set(::alloc::string::String::new()),
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
}
.insert($db)
.await
.expect("insert");
.expect("insert"),
);
};
}
pub(crate) use make_info_season;
macro_rules! make_info_episode {
($db:expr, $show:expr, $season:expr, $episode:expr) => {
drop(
$crate::entity::info::episodes::ActiveModel {
show_id: Set(::flix_model::id::ShowId::from_raw($show)),
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
title: Set(::std::string::String::new()),
overview: Set(::std::string::String::new()),
title: Set(::alloc::string::String::new()),
overview: Set(::alloc::string::String::new()),
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
}
.insert($db)
.await
.expect("insert");
.expect("insert"),
);
};
}
pub(crate) use make_info_episode;
@@ -355,7 +370,10 @@ mod tests {
make_info_collection, make_info_episode, make_info_movie, make_info_season, make_info_show,
};
#[cfg(not(miri))]
#[tokio::test]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn use_test_macros() {
let db = new_initialized_memory_db().await;
@@ -366,8 +384,10 @@ mod tests {
make_info_episode!(&db, 1, 1, 1);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_collections() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn round_trip_collections() {
let db = new_initialized_memory_db().await;
macro_rules! assert_collection {
@@ -409,8 +429,10 @@ mod tests {
assert_collection!(&db, 8, NotNullViolation; web_slug);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_movies() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn round_trip_movies() {
let db = new_initialized_memory_db().await;
macro_rules! assert_movie {
@@ -458,8 +480,10 @@ mod tests {
assert_movie!(&db, 10, NotNullViolation; web_slug);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_shows() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn round_trip_shows() {
let db = new_initialized_memory_db().await;
macro_rules! assert_show {
@@ -510,8 +534,11 @@ mod tests {
assert_show!(&db, 10, NotNullViolation; web_slug);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_seasons() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn round_trip_seasons() {
let db = new_initialized_memory_db().await;
macro_rules! assert_season {
@@ -561,8 +588,11 @@ mod tests {
assert_season!(&db, 1, 7, NotNullViolation; date);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_episodes() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn round_trip_episodes() {
let db = new_initialized_memory_db().await;
macro_rules! assert_episode {
+107 -57
View File
@@ -1,4 +1,10 @@
//! Entity structs for interacting with the database
//! Entity structs for interacting with the database.
#![expect(clippy::exhaustive_enums, reason = "reflects the database schema")]
#![expect(clippy::exhaustive_structs, reason = "reflects the database schema")]
#![expect(clippy::derive_partial_eq_without_eq, reason = "#[sea_orm::model]")]
#![expect(clippy::impl_trait_in_params, reason = "#[sea_orm::model]")]
#![expect(clippy::same_name_method, reason = "#[sea_orm::model]")]
pub mod content;
pub mod info;
@@ -13,8 +19,6 @@ mod tests {
use flix_tmdb::model::id::TmdbRepr as TmdbReprId;
use sea_orm::ActiveValue::Set;
use sea_orm::DatabaseConnection;
use sea_orm::DbErr;
use sea_orm::entity::prelude::*;
use sea_orm::sqlx::error::ErrorKind;
use seamantic::model::id::SeaOrmRepr as SeaOrmReprId;
@@ -32,35 +36,36 @@ mod tests {
use crate::tests::new_initialized_memory_db;
#[derive(Debug)]
pub enum ErrorKindError {
NotRuntimeError,
NotSqlxError,
NotDatabaseError,
pub(crate) enum ErrorKindNot {
Runtime,
Sqlx,
Database,
}
pub fn get_error_kind(error: DbErr) -> Result<ErrorKind, ErrorKindError> {
let runtime_err = match error {
DbErr::Conn(runtime_err) => runtime_err,
DbErr::Exec(runtime_err) => runtime_err,
DbErr::Query(runtime_err) => runtime_err,
_ => return Err(ErrorKindError::NotRuntimeError),
/// Extract the error kind from a [`DbErr`].
///
/// # Errors
/// If the [`DbErr`] internals diverge from [`SqlxError::Database`].
pub(crate) fn get_error_kind(error: DbErr) -> Result<ErrorKind, ErrorKindNot> {
let (DbErr::Conn(runtime_err) | DbErr::Exec(runtime_err) | DbErr::Query(runtime_err)) =
error
else {
return Err(ErrorKindNot::Runtime);
};
let sqlx_err = match runtime_err {
sea_orm::RuntimeErr::SqlxError(sqlx_err) => sqlx_err,
_ => return Err(ErrorKindError::NotSqlxError),
let RuntimeErr::SqlxError(sqlx_err) = runtime_err else {
return Err(ErrorKindNot::Sqlx);
};
let database_err = match sqlx_err.as_ref() {
sea_orm::SqlxError::Database(database_err) => database_err,
_ => return Err(ErrorKindError::NotDatabaseError),
let SqlxError::Database(database_err) = sqlx_err.as_ref() else {
return Err(ErrorKindNot::Database);
};
Ok(database_err.kind())
}
/// Helper macro for writing tests for `ActiveModel` structs where
/// toggling [sea_orm::ActiveValue] is needed
/// toggling [`sea_orm::ActiveValue`] is needed.
macro_rules! notsettable {
($field:ident, $value:expr $(, $($skip:ident),+)?) => {
if notsettable!(@skip, $field $(, $($skip),+)?) {
@@ -76,7 +81,7 @@ mod tests {
pub(super) use notsettable;
/// Helper macro for writing tests for `ActiveModel` structs where
/// toggling [sea_orm::ActiveValue] is needed
/// toggling [`sea_orm::ActiveValue`] is needed.
macro_rules! noneable {
($field:ident, $value:expr $(, $($skip:ident),+)?) => {
if noneable!(@skip, $field $(, $($skip),+)?) {
@@ -148,6 +153,11 @@ mod tests {
}
}
/// Initialize a database with data to be deleted.
///
/// # Panics
/// If any database operation fails, since this is only for testing.
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn initialize_deletion_test_database(id: &DbId) -> DatabaseConnection {
let db = new_initialized_memory_db().await;
@@ -284,12 +294,14 @@ mod tests {
db
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_delete_info_collection() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_info_collection() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::info::collections::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
_ = entity::info::collections::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
id.flix.collection,
))
.exec(&db)
@@ -368,12 +380,14 @@ mod tests {
);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_delete_info_movie() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_info_movie() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::info::movies::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
_ = entity::info::movies::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
id.flix.movie,
))
.exec(&db)
@@ -452,12 +466,16 @@ mod tests {
);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_delete_info_show() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_info_show() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::info::shows::Entity::delete_by_id(seamantic::model::id::Id::from_raw(id.flix.show))
_ = entity::info::shows::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
id.flix.show,
))
.exec(&db)
.await
.expect("Entity::delete_by_id");
@@ -534,12 +552,14 @@ mod tests {
);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_delete_info_season() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_info_season() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::info::seasons::Entity::delete_by_id((
_ = entity::info::seasons::Entity::delete_by_id((
seamantic::model::id::Id::from_raw(id.flix.show),
SeasonNumber::new(id.flix.season),
))
@@ -619,12 +639,14 @@ mod tests {
);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_delete_info_episodes() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_info_episodes() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::info::episodes::Entity::delete_by_id((
_ = entity::info::episodes::Entity::delete_by_id((
seamantic::model::id::Id::from_raw(id.flix.show),
SeasonNumber::new(id.flix.season),
EpisodeNumber::new(id.flix.episode),
@@ -705,13 +727,15 @@ mod tests {
);
}
#[cfg(not(miri))]
#[cfg(feature = "tmdb")]
#[tokio::test]
async fn test_delete_tmdb_collection() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_tmdb_collection() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::tmdb::collections::Entity::delete_by_id(flix_tmdb::model::id::Id::from_raw(
_ = entity::tmdb::collections::Entity::delete_by_id(flix_tmdb::model::id::Id::from_raw(
id.tmdb.collection,
))
.exec(&db)
@@ -790,13 +814,15 @@ mod tests {
);
}
#[cfg(not(miri))]
#[cfg(feature = "tmdb")]
#[tokio::test]
async fn test_delete_tmdb_movie() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_tmdb_movie() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::tmdb::movies::Entity::delete_by_id(flix_tmdb::model::id::Id::from_raw(
_ = entity::tmdb::movies::Entity::delete_by_id(flix_tmdb::model::id::Id::from_raw(
id.tmdb.movie,
))
.exec(&db)
@@ -875,13 +901,17 @@ mod tests {
);
}
#[cfg(not(miri))]
#[cfg(feature = "tmdb")]
#[tokio::test]
async fn test_delete_tmdb_show() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_tmdb_show() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::tmdb::shows::Entity::delete_by_id(flix_tmdb::model::id::Id::from_raw(id.tmdb.show))
_ = entity::tmdb::shows::Entity::delete_by_id(flix_tmdb::model::id::Id::from_raw(
id.tmdb.show,
))
.exec(&db)
.await
.expect("Entity::delete_by_id");
@@ -958,13 +988,15 @@ mod tests {
);
}
#[cfg(not(miri))]
#[cfg(feature = "tmdb")]
#[tokio::test]
async fn test_delete_tmdb_season() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_tmdb_season() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::tmdb::seasons::Entity::delete_by_id((
_ = entity::tmdb::seasons::Entity::delete_by_id((
flix_tmdb::model::id::Id::from_raw(id.tmdb.show),
SeasonNumber::new(id.tmdb.season),
))
@@ -1044,13 +1076,15 @@ mod tests {
);
}
#[cfg(not(miri))]
#[cfg(feature = "tmdb")]
#[tokio::test]
async fn test_delete_tmdb_episode() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_tmdb_episode() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::tmdb::episodes::Entity::delete_by_id((
_ = entity::tmdb::episodes::Entity::delete_by_id((
flix_tmdb::model::id::Id::from_raw(id.tmdb.show),
SeasonNumber::new(id.tmdb.season),
EpisodeNumber::new(id.tmdb.episode),
@@ -1131,12 +1165,14 @@ mod tests {
);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_delete_content_library() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_content_library() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::content::libraries::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
_ = entity::content::libraries::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
id.content.library,
))
.exec(&db)
@@ -1215,12 +1251,14 @@ mod tests {
);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_delete_content_collection() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_content_collection() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::content::collections::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
_ = entity::content::collections::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
id.flix.collection,
))
.exec(&db)
@@ -1299,12 +1337,14 @@ mod tests {
);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_delete_content_movie() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_content_movie() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::content::movies::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
_ = entity::content::movies::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
id.flix.movie,
))
.exec(&db)
@@ -1383,12 +1423,14 @@ mod tests {
);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_delete_content_show() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_content_show() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::content::shows::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
_ = entity::content::shows::Entity::delete_by_id(seamantic::model::id::Id::from_raw(
id.flix.show,
))
.exec(&db)
@@ -1467,12 +1509,14 @@ mod tests {
);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_delete_content_season() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_content_season() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::content::seasons::Entity::delete_by_id((
_ = entity::content::seasons::Entity::delete_by_id((
seamantic::model::id::Id::from_raw(id.flix.show),
SeasonNumber::new(id.flix.season),
))
@@ -1552,12 +1596,14 @@ mod tests {
);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_delete_content_episode() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_content_episode() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::content::episodes::Entity::delete_by_id((
_ = entity::content::episodes::Entity::delete_by_id((
seamantic::model::id::Id::from_raw(id.flix.show),
SeasonNumber::new(id.flix.season),
EpisodeNumber::new(id.flix.episode),
@@ -1638,12 +1684,14 @@ mod tests {
);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_delete_watched_movie() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_watched_movie() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::watched::movies::Entity::delete_by_id((
_ = entity::watched::movies::Entity::delete_by_id((
seamantic::model::id::Id::from_raw(id.flix.movie),
id.watch.user,
))
@@ -1723,12 +1771,14 @@ mod tests {
);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_delete_watched_episode() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn delete_watched_episode() {
let id = DbId::default();
let db = initialize_deletion_test_database(&id).await;
entity::watched::episodes::Entity::delete_by_id((
_ = entity::watched::episodes::Entity::delete_by_id((
seamantic::model::id::Id::from_raw(id.flix.show),
SeasonNumber::new(id.flix.season),
EpisodeNumber::new(id.flix.episode),
+84 -61
View File
@@ -1,6 +1,6 @@
//! This module contains entities for storing dynamic data from TMDB
//! This module contains entities for storing dynamic data from TMDB.
/// Collection entity
/// Collection entity.
pub mod collections {
use flix_model::id::CollectionId as FlixId;
use flix_tmdb::model::id::CollectionId;
@@ -10,23 +10,23 @@ pub mod collections {
use crate::entity;
/// The database representation of a tmdb collection
/// The database representation of a tmdb collection.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_tmdb_collections")]
pub struct Model {
/// The collection's TMDB ID
/// The collection's TMDB ID.
#[sea_orm(primary_key, auto_increment = false)]
pub tmdb_id: CollectionId,
/// The collection's ID
/// The collection's ID.
#[sea_orm(unique)]
pub flix_id: FlixId,
/// The date of the last update
/// The date of the last update.
pub last_update: DateTime<Utc>,
/// The number of movies in the collection
/// The number of movies in the collection.
pub movie_count: u16,
/// The info for this collection
/// The info for this collection.
#[sea_orm(
belongs_to,
from = "flix_id",
@@ -36,15 +36,16 @@ pub mod collections {
)]
pub info: HasOne<entity::info::collections::Entity>,
/// Movies that are in this collection
/// Movies that are in this collection.
#[sea_orm(has_many)]
pub movies: HasMany<super::movies::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Movie entity
/// Movie entity.
pub mod movies {
use flix_model::id::MovieId as FlixId;
use flix_tmdb::model::id::{CollectionId, MovieId};
@@ -56,26 +57,26 @@ pub mod movies {
use crate::entity;
/// The database representation of a tmdb movie
/// The database representation of a tmdb movie.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_tmdb_movies")]
pub struct Model {
/// The movie's TMDB ID
/// The movie's TMDB ID.
#[sea_orm(primary_key, auto_increment = false)]
pub tmdb_id: MovieId,
/// The movie's ID
/// The movie's ID.
#[sea_orm(unique)]
pub flix_id: FlixId,
/// The date of the last update
/// The date of the last update.
pub last_update: DateTime<Utc>,
/// The movie's runtime in seconds
/// The movie's runtime in seconds.
pub runtime: Seconds,
/// The TMDB ID of the collection this movie belongs to
/// The TMDB ID of the collection this movie belongs to.
#[sea_orm(indexed)]
pub collection_id: Option<CollectionId>,
/// The collection this movie belongs to
/// The collection this movie belongs to.
#[sea_orm(
belongs_to,
from = "collection_id",
@@ -84,7 +85,7 @@ pub mod movies {
on_delete = "Cascade"
)]
pub collection: HasOne<super::collections::Entity>,
/// The info for this movie
/// The info for this movie.
#[sea_orm(
belongs_to,
from = "flix_id",
@@ -95,10 +96,11 @@ pub mod movies {
pub info: HasOne<entity::info::movies::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Show entity
/// Show entity.
pub mod shows {
use flix_model::id::ShowId as FlixId;
use flix_tmdb::model::id::ShowId;
@@ -108,23 +110,23 @@ pub mod shows {
use crate::entity;
/// The database representation of a tmdb show
/// The database representation of a tmdb show.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_tmdb_shows")]
pub struct Model {
/// The show's TMDB ID
/// The show's TMDB ID.
#[sea_orm(primary_key, auto_increment = false)]
pub tmdb_id: ShowId,
/// The show's ID
/// The show's ID.
#[sea_orm(unique)]
pub flix_id: FlixId,
/// The movie's runtime in seconds
/// The movie's runtime in seconds.
pub last_update: DateTime<Utc>,
/// The number of seasons the show has
/// The number of seasons the show has.
pub number_of_seasons: u32,
/// The info for this show
/// The info for this show.
#[sea_orm(
belongs_to,
from = "flix_id",
@@ -134,18 +136,19 @@ pub mod shows {
)]
pub info: HasOne<entity::info::shows::Entity>,
/// Seasons that are part of this show
/// Seasons that are part of this show.
#[sea_orm(has_many)]
pub seasons: HasMany<super::seasons::Entity>,
/// Episodes that are part of this show
/// Episodes that are part of this show.
#[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Season entity
/// Season entity.
pub mod seasons {
use flix_model::id::ShowId as FlixId;
use flix_model::numbers::SeasonNumber;
@@ -156,27 +159,27 @@ pub mod seasons {
use crate::entity;
/// The database representation of a tmdb season
/// The database representation of a tmdb season.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_tmdb_seasons")]
pub struct Model {
/// The season's show's TMDB ID
/// The season's show's TMDB ID.
#[sea_orm(primary_key, auto_increment = false)]
pub tmdb_show: ShowId,
/// The season's TMDB season number
/// The season's TMDB season number.
#[sea_orm(primary_key, auto_increment = false)]
pub tmdb_season: SeasonNumber,
/// The season's show's ID
/// The season's show's ID.
#[sea_orm(unique_key = "flix")]
pub flix_show: FlixId,
/// The season's number
/// The season's number.
#[sea_orm(unique_key = "flix")]
pub flix_season: SeasonNumber,
/// The date of the last update
/// The date of the last update.
pub last_update: DateTime<Utc>,
/// The show this season belongs to
/// The show this season belongs to.
#[sea_orm(
belongs_to,
from = "tmdb_show",
@@ -185,7 +188,7 @@ pub mod seasons {
on_delete = "Cascade"
)]
pub show: HasOne<super::shows::Entity>,
/// The info for this season
/// The info for this season.
#[sea_orm(
belongs_to,
from = "(flix_show, flix_season)",
@@ -195,15 +198,16 @@ pub mod seasons {
)]
pub info: HasOne<entity::info::seasons::Entity>,
/// Episodes that are part of this season
/// Episodes that are part of this season.
#[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Season entity
/// Season entity.
pub mod episodes {
use flix_model::id::ShowId as FlixId;
use flix_model::numbers::{EpisodeNumber, SeasonNumber};
@@ -215,35 +219,35 @@ pub mod episodes {
use crate::entity;
/// The database representation of a tmdb episode
/// The database representation of a tmdb episode.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_tmdb_episodes")]
pub struct Model {
/// The episode's show's TMDB ID
/// The episode's show's TMDB ID.
#[sea_orm(primary_key, auto_increment = false)]
pub tmdb_show: ShowId,
/// The episode's season's TMDB season number
/// The episode's season's TMDB season number.
#[sea_orm(primary_key, auto_increment = false)]
pub tmdb_season: SeasonNumber,
/// The episode's TMDB episode number
/// The episode's TMDB episode number.
#[sea_orm(primary_key, auto_increment = false)]
pub tmdb_episode: EpisodeNumber,
/// The episode's show's ID
/// The episode's show's ID.
#[sea_orm(unique_key = "flix")]
pub flix_show: FlixId,
/// The episode's season's number
/// The episode's season's number.
#[sea_orm(unique_key = "flix")]
pub flix_season: SeasonNumber,
/// The episode's number
/// The episode's number.
#[sea_orm(unique_key = "flix")]
pub flix_episode: EpisodeNumber,
/// The date of the last update
/// The date of the last update.
pub last_update: DateTime<Utc>,
/// The episode's runtime in seconds
/// The episode's runtime in seconds.
pub runtime: Seconds,
/// The show this episode belongs to
/// The show this episode belongs to.
#[sea_orm(
belongs_to,
from = "tmdb_show",
@@ -252,7 +256,7 @@ pub mod episodes {
on_delete = "Cascade"
)]
pub show: HasOne<super::shows::Entity>,
/// The season this episode belongs to
/// The season this episode belongs to.
#[sea_orm(
belongs_to,
from = "(tmdb_show, tmdb_season)",
@@ -261,7 +265,7 @@ pub mod episodes {
on_delete = "Cascade"
)]
pub season: HasOne<super::seasons::Entity>,
/// The info for this episode
/// The info for this episode.
#[sea_orm(
belongs_to,
from = "(flix_show, flix_season, flix_episode)",
@@ -272,15 +276,16 @@ pub mod episodes {
pub info: HasOne<entity::info::episodes::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Macros for creating tmdb entities
/// Macros for creating tmdb entities.
#[cfg(test)]
pub mod test {
macro_rules! make_tmdb_collection {
($db:expr, $id:expr, $flix_id:expr) => {
$crate::entity::tmdb::collections::ActiveModel {
_ = $crate::entity::tmdb::collections::ActiveModel {
tmdb_id: Set(::flix_tmdb::model::id::CollectionId::from_raw($id)),
flix_id: Set(::flix_model::id::CollectionId::from_raw($flix_id)),
last_update: Set(::chrono::Utc::now()),
@@ -295,7 +300,7 @@ pub mod test {
macro_rules! make_tmdb_movie {
($db:expr, $id:expr, $flix_id:expr) => {
$crate::entity::tmdb::movies::ActiveModel {
_ = $crate::entity::tmdb::movies::ActiveModel {
tmdb_id: Set(::flix_tmdb::model::id::MovieId::from_raw($id)),
flix_id: Set(::flix_model::id::MovieId::from_raw($flix_id)),
last_update: Set(::chrono::Utc::now()),
@@ -311,7 +316,7 @@ pub mod test {
macro_rules! make_tmdb_show {
($db:expr, $id:expr, $flix_id:expr) => {
$crate::entity::tmdb::shows::ActiveModel {
_ = $crate::entity::tmdb::shows::ActiveModel {
tmdb_id: Set(::flix_tmdb::model::id::ShowId::from_raw($id)),
flix_id: Set(::flix_model::id::ShowId::from_raw($flix_id)),
last_update: Set(::chrono::Utc::now()),
@@ -326,7 +331,7 @@ pub mod test {
macro_rules! make_tmdb_season {
($db:expr, $show:expr, $season:expr, $flix_show:expr, $flix_season:expr) => {
$crate::entity::tmdb::seasons::ActiveModel {
_ = $crate::entity::tmdb::seasons::ActiveModel {
tmdb_show: Set(::flix_tmdb::model::id::ShowId::from_raw($show)),
tmdb_season: Set(::flix_model::numbers::SeasonNumber::new($season)),
flix_show: Set(::flix_model::id::ShowId::from_raw($flix_show)),
@@ -342,7 +347,7 @@ pub mod test {
macro_rules! make_tmdb_episode {
($db:expr, $show:expr, $season:expr, $episode:expr, $flix_show:expr, $flix_season:expr, $flix_episode:expr) => {
$crate::entity::tmdb::episodes::ActiveModel {
_ = $crate::entity::tmdb::episodes::ActiveModel {
tmdb_show: Set(::flix_tmdb::model::id::ShowId::from_raw($show)),
tmdb_season: Set(::flix_model::numbers::SeasonNumber::new($season)),
tmdb_episode: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
@@ -385,7 +390,10 @@ mod tests {
make_tmdb_collection, make_tmdb_episode, make_tmdb_movie, make_tmdb_season, make_tmdb_show,
};
#[cfg(not(miri))]
#[tokio::test]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn use_test_macros() {
let db = new_initialized_memory_db().await;
@@ -402,8 +410,11 @@ mod tests {
make_tmdb_episode!(&db, 1, 1, 1, 1, 1, 1);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_collections() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn round_trip_collections() {
let db = new_initialized_memory_db().await;
macro_rules! assert_collection {
@@ -449,8 +460,11 @@ mod tests {
assert_collection!(&db, 6, 6, NotNullViolation; movie_count);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_movies() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn round_trip_movies() {
let db = new_initialized_memory_db().await;
macro_rules! assert_movie {
@@ -502,8 +516,11 @@ mod tests {
assert_movie!(&db, 7, 7, None, ForeignKeyViolation; collection_id);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_shows() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn round_trip_shows() {
let db = new_initialized_memory_db().await;
macro_rules! assert_show {
@@ -552,8 +569,11 @@ mod tests {
assert_show!(&db, 6, 6, NotNullViolation; number_of_seasons);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_seasons() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn round_trip_seasons() {
let db = new_initialized_memory_db().await;
macro_rules! assert_season {
@@ -607,8 +627,11 @@ mod tests {
assert_season!(&db, 1, 7, 1, 7, NotNullViolation; last_update);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_episodes() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn round_trip_episodes() {
let db = new_initialized_memory_db().await;
macro_rules! assert_episode {
+73 -50
View File
@@ -1,6 +1,6 @@
//! This module contains entities for storing watched information
//! This module contains entities for storing watched information.
/// Collection entity
/// Collection entity.
pub mod collections {
use flix_model::id::{CollectionId, RawId};
@@ -9,21 +9,21 @@ pub mod collections {
use crate::entity;
/// The database representation of a watched movie
/// The database representation of a watched movie.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_watched_collections")]
pub struct Model {
/// The collection's ID
/// The collection's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub id: CollectionId,
/// The user's ID
/// The user's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: RawId,
/// The date this collection was watched
/// The date this collection was watched.
pub watched_date: DateTime<Utc>,
/// The info for this collection
/// The info for this collection.
#[sea_orm(
belongs_to,
relation_enum = "Info",
@@ -33,15 +33,16 @@ pub mod collections {
on_delete = "Cascade"
)]
pub info: HasOne<entity::info::collections::Entity>,
/// The content for this collection
/// The content for this collection.
#[sea_orm(belongs_to, relation_enum = "Content", from = "id", to = "id", skip_fk)]
pub content: HasOne<entity::content::collections::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Movie entity
/// Movie entity.
pub mod movies {
use flix_model::id::{MovieId, RawId};
@@ -50,21 +51,21 @@ pub mod movies {
use crate::entity;
/// The database representation of a watched movie
/// The database representation of a watched movie.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_watched_movies")]
pub struct Model {
/// The movie's ID
/// The movie's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub id: MovieId,
/// The user's ID
/// The user's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: RawId,
/// The date this movie was watched
/// The date this movie was watched.
pub watched_date: DateTime<Utc>,
/// The info for this movie
/// The info for this movie.
#[sea_orm(
belongs_to,
from = "id",
@@ -73,15 +74,16 @@ pub mod movies {
on_delete = "Cascade"
)]
pub info: HasOne<entity::info::movies::Entity>,
/// The content for this movie
/// The content for this movie.
#[sea_orm(belongs_to, relation_enum = "Content", from = "id", to = "id", skip_fk)]
pub content: HasOne<entity::content::movies::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Show entity
/// Show entity.
pub mod shows {
use flix_model::id::{RawId, ShowId};
@@ -90,21 +92,21 @@ pub mod shows {
use crate::entity;
/// The database representation of a watched movie
/// The database representation of a watched movie.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_watched_shows")]
pub struct Model {
/// The show's ID
/// The show's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub id: ShowId,
/// The user's ID
/// The user's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: RawId,
/// The date this show was watched
/// The date this show was watched.
pub watched_date: DateTime<Utc>,
/// The info for this show
/// The info for this show.
#[sea_orm(
belongs_to,
relation_enum = "Info",
@@ -114,15 +116,16 @@ pub mod shows {
on_delete = "Cascade"
)]
pub info: HasOne<entity::info::shows::Entity>,
/// The content for this show
/// The content for this show.
#[sea_orm(belongs_to, relation_enum = "Content", from = "id", to = "id", skip_fk)]
pub content: HasOne<entity::content::shows::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Season entity
/// Season entity.
pub mod seasons {
use flix_model::id::{RawId, ShowId};
use flix_model::numbers::SeasonNumber;
@@ -132,24 +135,24 @@ pub mod seasons {
use crate::entity;
/// The database representation of a watched movie
/// The database representation of a watched movie.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_watched_seasons")]
pub struct Model {
/// The season's show's ID
/// The season's show's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub show_id: ShowId,
/// The season's number
/// The season's number.
#[sea_orm(primary_key, auto_increment = false)]
pub season_number: SeasonNumber,
/// The user's ID
/// The user's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: RawId,
/// The date this season was watched
/// The date this season was watched.
pub watched_date: DateTime<Utc>,
/// The info for this season
/// The info for this season.
#[sea_orm(
belongs_to,
relation_enum = "Info",
@@ -159,7 +162,7 @@ pub mod seasons {
on_delete = "Cascade"
)]
pub info: HasOne<entity::info::seasons::Entity>,
/// The content for this season
/// The content for this season.
#[sea_orm(
belongs_to,
relation_enum = "Content",
@@ -170,10 +173,11 @@ pub mod seasons {
pub content: HasOne<entity::content::seasons::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Episode entity
/// Episode entity.
pub mod episodes {
use flix_model::id::{RawId, ShowId};
use flix_model::numbers::{EpisodeNumber, SeasonNumber};
@@ -183,27 +187,27 @@ pub mod episodes {
use crate::entity;
/// The database representation of a watched movie
/// The database representation of a watched movie.
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_watched_episodes")]
pub struct Model {
/// The episode's show's ID
/// The episode's show's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub show_id: ShowId,
/// The episode's season's number
/// The episode's season's number.
#[sea_orm(primary_key, auto_increment = false)]
pub season_number: SeasonNumber,
/// The episode's number
/// The episode's number.
#[sea_orm(primary_key, auto_increment = false)]
pub episode_number: EpisodeNumber,
/// The user's ID
/// The user's ID.
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: RawId,
/// The date this episode was watched
/// The date this episode was watched.
pub watched_date: DateTime<Utc>,
/// The info for this episode
/// The info for this episode.
#[sea_orm(
belongs_to,
relation_enum = "Info",
@@ -213,7 +217,7 @@ pub mod episodes {
on_delete = "Cascade"
)]
pub info: HasOne<entity::info::episodes::Entity>,
/// The content for this episode
/// The content for this episode.
#[sea_orm(
belongs_to,
relation_enum = "Content",
@@ -224,15 +228,16 @@ pub mod episodes {
pub content: HasOne<entity::content::episodes::Entity>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
}
/// Macros for creating watched entities
/// Macros for creating watched entities.
#[cfg(test)]
pub mod test {
macro_rules! make_watched_movie {
($db:expr, $id:expr, $user:expr) => {
$crate::entity::watched::movies::ActiveModel {
_ = $crate::entity::watched::movies::ActiveModel {
id: Set(::flix_model::id::MovieId::from_raw($id)),
user_id: Set($user),
watched_date: Set(::chrono::Utc::now()),
@@ -246,7 +251,7 @@ pub mod test {
macro_rules! make_watched_episode {
($db:expr, $show:expr, $season:expr, $episode:expr, $user:expr) => {
$crate::entity::watched::episodes::ActiveModel {
_ = $crate::entity::watched::episodes::ActiveModel {
show_id: Set(::flix_model::id::ShowId::from_raw($show)),
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
@@ -284,7 +289,10 @@ mod tests {
use super::super::tests::notsettable;
use super::test::{make_watched_episode, make_watched_movie};
#[cfg(not(miri))]
#[tokio::test]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn use_test_macros() {
let db = new_initialized_memory_db().await;
@@ -297,8 +305,11 @@ mod tests {
make_watched_episode!(&db, 1, 1, 1, 1);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_movies() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn round_trip_movies() {
let db = new_initialized_memory_db().await;
macro_rules! assert_movie {
@@ -340,8 +351,11 @@ mod tests {
assert_movie!(&db, 5, 1, NotNullViolation; watched_date);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_round_trip_episodes() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn round_trip_episodes() {
let db = new_initialized_memory_db().await;
macro_rules! assert_episode {
@@ -394,13 +408,16 @@ mod tests {
assert_episode!(&db, 7, 1, 1, 1, NotNullViolation; watched_date);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_query_seasons() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn query_seasons() {
let db = new_initialized_memory_db().await;
macro_rules! assert_season {
($db:expr, $show:literal, $season:literal, $uid:literal, Watched) => {
assert_season!(@find, $db, $show, $season, $uid)
_ = assert_season!(@find, $db, $show, $season, $uid)
.ok_or(())
.expect("is none");
};
@@ -462,13 +479,16 @@ mod tests {
assert_season!(&db, 1, 2, 3, Unwatched);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_query_shows() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn query_shows() {
let db = new_initialized_memory_db().await;
macro_rules! assert_show {
($db:expr, $show:literal, $uid:literal, Watched) => {
assert_show!(@find, $db, $show, $uid)
_ = assert_show!(@find, $db, $show, $uid)
.ok_or(())
.expect("is none");
};
@@ -517,13 +537,16 @@ mod tests {
assert_show!(&db, 1, 2, Unwatched);
}
#[cfg(not(miri))]
#[tokio::test]
async fn test_query_collections() {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn query_collections() {
let db = new_initialized_memory_db().await;
macro_rules! assert_collection {
($db:expr, $id:literal, $uid:literal, Watched) => {
assert_collection!(@find, $db, $id, $uid)
_ = assert_collection!(@find, $db, $id, $uid)
.ok_or(())
.expect("is none");
};
+6 -2
View File
@@ -1,7 +1,10 @@
//! flix-db provides types for storing persistent data about media
//! flix-db provides types for storing persistent data about media.
#![cfg_attr(docsrs, feature(doc_cfg))]
#[cfg(test)]
extern crate alloc;
pub mod connection;
pub mod entity;
pub mod migration;
@@ -12,7 +15,8 @@ mod tests {
use crate::connection::Connection;
pub async fn new_initialized_memory_db() -> DatabaseConnection {
#[expect(clippy::missing_panics_doc, reason = "unit test")]
pub(crate) async fn new_initialized_memory_db() -> DatabaseConnection {
let options = ConnectOptions::new("sqlite::memory:");
let db = Database::connect(options)
@@ -1,18 +1,22 @@
//! Custom views for collections.
use sea_orm::prelude::*;
use sea_orm::sea_query::Table;
use sea_orm::{ConnectionTrait, DbBackend, Statement};
use sea_orm::{DbBackend, Statement};
use sea_orm_migration::prelude::*;
pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
/// # Errors
/// Database operations can fail.
pub(crate) async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table("flix_watched_collections").to_owned())
.await?;
manager
_ = manager
.get_connection()
.execute_raw(Statement::from_string(
DbBackend::Sqlite,
r#"
"
CREATE VIEW flix_watched_collections AS
WITH RECURSIVE
watched_items AS (
@@ -81,22 +85,24 @@ pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
)
GROUP BY ci.parent_id, wi.user_id
;
"#,
",
))
.await?;
Ok(())
}
pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
manager
/// # Errors
/// Database operations can fail.
pub(crate) async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
_ = manager
.get_connection()
.execute_raw(Statement::from_string(
DbBackend::Sqlite,
r#"
"
DROP VIEW flix_watched_collections
;
"#,
",
))
.await?;
@@ -11,10 +11,16 @@ mod collections;
mod seasons;
mod shows;
/// The migration entry point.
#[derive(DeriveMigrationName)]
pub(super) struct Migration;
#[async_trait::async_trait]
#[expect(
elided_lifetimes_in_paths,
reason = "async_trait causes lifetimes to be strange"
)]
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
seasons::up(manager).await?;
+15 -9
View File
@@ -1,18 +1,22 @@
//! Custom views for seasons.
use sea_orm::prelude::*;
use sea_orm::sea_query::Table;
use sea_orm::{ConnectionTrait, DbBackend, Statement};
use sea_orm::{DbBackend, Statement};
use sea_orm_migration::prelude::*;
pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
/// # Errors
/// Database operations can fail.
pub(crate) async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table("flix_watched_seasons").to_owned())
.await?;
manager
_ = manager
.get_connection()
.execute_raw(Statement::from_string(
DbBackend::Sqlite,
r#"
"
CREATE VIEW flix_watched_seasons AS
SELECT
w.show_id,
@@ -36,22 +40,24 @@ pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
)
GROUP BY w.show_id, w.season_number, w.user_id
;
"#,
",
))
.await?;
Ok(())
}
pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
manager
/// # Errors
/// Database operations can fail.
pub(crate) async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
_ = manager
.get_connection()
.execute_raw(Statement::from_string(
DbBackend::Sqlite,
r#"
"
DROP VIEW flix_watched_seasons
;
"#,
",
))
.await?;
+15 -9
View File
@@ -1,18 +1,22 @@
//! Custom views for shows.
use sea_orm::prelude::*;
use sea_orm::sea_query::Table;
use sea_orm::{ConnectionTrait, DbBackend, Statement};
use sea_orm::{DbBackend, Statement};
use sea_orm_migration::prelude::*;
pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
/// # Errors
/// Database operations can fail.
pub(crate) async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table("flix_watched_shows").to_owned())
.await?;
manager
_ = manager
.get_connection()
.execute_raw(Statement::from_string(
DbBackend::Sqlite,
r#"
"
CREATE VIEW flix_watched_shows AS
SELECT
w.show_id as id,
@@ -33,22 +37,24 @@ pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
)
GROUP BY w.show_id, w.user_id
;
"#,
",
))
.await?;
Ok(())
}
pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
manager
/// # Errors
/// Database operations can fail.
pub(crate) async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
_ = manager
.get_connection()
.execute_raw(Statement::from_string(
DbBackend::Sqlite,
r#"
"
DROP VIEW flix_watched_shows
;
"#,
",
))
.await?;
+1 -1
View File
@@ -1,4 +1,4 @@
//! Migrations for maintaining the database schema
//! Migrations for maintaining the database schema.
seamantic::migrations! {
"seaql_migrations_flix";
+7 -5
View File
@@ -1,14 +1,16 @@
[package]
name = "flix"
version = "0.0.20"
license-file.workspace = true
version = "0.1.0"
description = "Mechanisms for interacting with flix media"
repository = "https://github.com/QuantumShade/flix"
categories = []
repository = "https://git.skrundz.dev/quantumshade/flix"
keywords = ["flix"]
categories = ["multimedia"]
include = ["/src"]
publish = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs]
all-features = true
+1 -1
View File
@@ -1,4 +1,4 @@
//! flix provides mechanisms for interacting with flix media
//! flix provides mechanisms for interacting with flix media.
#![cfg_attr(docsrs, feature(doc_cfg))]
+10 -5
View File
@@ -1,14 +1,16 @@
[package]
name = "flix-fs"
version = "0.0.20"
license-file.workspace = true
version = "0.1.0"
description = "Filesystem scanner for flix media"
repository = "https://github.com/QuantumShade/flix"
categories = []
repository = "https://git.skrundz.dev/quantumshade/flix"
keywords = ["flix"]
categories = ["multimedia"]
include = ["/src"]
publish = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs]
all-features = true
@@ -23,5 +25,8 @@ thiserror = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true, features = ["fs"] }
[dev-dependencies]
tempfile = { workspace = true }
[lints]
workspace = true
+14 -10
View File
@@ -1,39 +1,43 @@
//! Filesystem errors.
use std::io;
/// The error type for filesystem scanning operations.
#[derive(Debug, thiserror::Error)]
#[expect(clippy::error_impl_error, reason = "Error is a good name here")]
#[expect(clippy::exhaustive_enums, reason = "unlikely to add more variants")]
pub enum Error {
/// fs::read_dir failed
/// `fs::read_dir` failed.
#[error("fs::read_dir: {0}")]
ReadDir(io::Error),
/// fs::read_dir::next_entry failed
/// `fs::read_dir::next_entry` failed.
#[error("fs::read_dir::next_entry: {0}")]
ReadDirEntry(io::Error),
/// fs::read_dir::file_type failed
/// `fs::read_dir::file_type` failed.
#[error("fs::read_dir::file_type: {0}")]
FileType(io::Error),
/// There is an unexpected file in the directory
/// There is an unexpected file in the directory.
#[error("unexpected file")]
UnexpectedFile,
/// There is an unexpected folder in the directory
/// There is an unexpected folder in the directory.
#[error("unexpected folder")]
UnexpectedFolder,
/// There is an unexpected non-file item in the directory
/// There is an unexpected non-file item in the directory.
#[error("unexpected non-file")]
UnexpectedNonFile,
/// There are multiple media files in the directory
/// There are multiple media files in the directory.
#[error("duplicate media file")]
DuplicateMediaFile,
/// There are multiple poster files in the directory
/// There are multiple poster files in the directory.
#[error("duplicate poster file")]
DuplicatePosterFile,
/// The directory contains incomplete flix media
/// The directory contains incomplete flix media.
#[error("incomplete")]
Incomplete,
/// Some data is inconsistent with the folder structure
/// Some data is inconsistent with the folder structure.
#[error("inconsistent")]
Inconsistent,
}
+7 -4
View File
@@ -1,18 +1,21 @@
//! Filesystem scan result items.
use std::path::PathBuf;
use crate::Error;
/// An item returned by scanner streams
/// An item returned by scanner streams.
#[derive(Debug)]
#[expect(clippy::exhaustive_structs, reason = "unlikely to change")]
pub struct Item<T> {
/// The path of the item
/// The path of the item.
pub path: PathBuf,
/// The event relating to the item
/// The event relating to the item.
pub event: Result<T, Error>,
}
impl<T> Item<T> {
/// Helper function for mapping the inner event [Result]
/// Helper function for mapping the inner event [Result].
#[inline]
pub fn map<U, F: FnOnce(T) -> U>(self, op: F) -> Item<U> {
Item {
+1 -1
View File
@@ -1,4 +1,4 @@
//! flix-fs provides filesystem scanners for flix media
//! flix-fs provides filesystem scanners for flix media.
#![cfg_attr(docsrs, feature(doc_cfg))]
+4
View File
@@ -1,3 +1,6 @@
//! Helper macros.
/// Defines media file extensions.
macro_rules! is_media_extension {
() => {
Some("mp4" | "mkv")
@@ -5,6 +8,7 @@ macro_rules! is_media_extension {
}
pub(super) use is_media_extension;
/// Defines image file expensions.
macro_rules! is_image_extension {
() => {
Some("png" | "jpg")
+20 -10
View File
@@ -1,4 +1,4 @@
//! The collection scanner will scan a folder and its children
//! The collection scanner will scan a folder and its children.
use core::pin::Pin;
use std::ffi::OsStr;
@@ -17,24 +17,27 @@ use crate::scanner::{
CollectionScan, EpisodeScan, MediaRef, MovieScan, SeasonScan, ShowScan, generic, movie, show,
};
/// A collection item
/// A collection item.
pub type Item = crate::Item<Scanner>;
/// The scanner for collections
/// The scanner for collections.
#[derive(Debug)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner {
/// A scanned collection
/// A scanned collection.
Collection(CollectionScan),
/// A scanned movie
/// A scanned movie.
Movie(MovieScan),
/// A scanned show
/// A scanned show.
Show(ShowScan),
/// A scanned episode
/// A scanned episode.
Season(SeasonScan),
/// A scanned episode
/// A scanned episode.
Episode(EpisodeScan),
}
impl From<movie::Scanner> for Scanner {
#[inline]
fn from(value: movie::Scanner) -> Self {
match value {
movie::Scanner::Movie(m) => Self::Movie(m),
@@ -43,6 +46,7 @@ impl From<movie::Scanner> for Scanner {
}
impl From<show::Scanner> for Scanner {
#[inline]
fn from(value: show::Scanner) -> Self {
match value {
show::Scanner::Show(s) => Self::Show(s),
@@ -53,6 +57,7 @@ impl From<show::Scanner> for Scanner {
}
impl From<generic::Scanner> for Scanner {
#[inline]
fn from(value: generic::Scanner) -> Self {
match value {
generic::Scanner::Collection(c) => Self::Collection(c),
@@ -65,7 +70,12 @@ impl From<generic::Scanner> for Scanner {
}
impl Scanner {
/// Scan a folder for a collection
/// Scan a folder for a collection.
#[inline]
#[must_use]
#[expect(clippy::allow_attributes, reason = "for tail_expr_drop_order")]
#[allow(tail_expr_drop_order, reason = "bug in stream! macro")]
#[expect(clippy::semicolon_if_nothing_returned, reason = "false positive")]
pub fn scan_collection(
path: &Path,
parent_ref: Option<MediaRef<CollectionId>>,
@@ -151,7 +161,7 @@ impl Scanner {
for await event in
generic::Scanner::scan_detect_folder(&subdir, Some(id_ref.clone()))
{
yield event.map(|e| e.into());
yield event.map(Into::into);
}
}
}))
+12 -5
View File
@@ -1,4 +1,4 @@
//! The episode scanner will scan a folder and exit
//! The episode scanner will scan a folder and exit.
use std::ffi::OsStr;
use std::path::Path;
@@ -15,17 +15,23 @@ use crate::Error;
use crate::macros::{is_image_extension, is_media_extension};
use crate::scanner::{EpisodeScan, MediaRef};
/// An episode item
/// An episode item.
pub type Item = crate::Item<Scanner>;
/// The scanner for epispdes
/// The scanner for epispdes.
#[derive(Debug)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner {
/// A scanned episode
/// A scanned episode.
Episode(EpisodeScan),
}
impl Scanner {
/// Scan a folder for an episode
/// Scan a folder for an episode.
#[inline]
#[expect(clippy::allow_attributes, reason = "for tail_expr_drop_order")]
#[allow(tail_expr_drop_order, reason = "bug in stream! macro")]
#[expect(clippy::semicolon_if_nothing_returned, reason = "false positive")]
pub fn scan_episode(
path: &Path,
show_ref: MediaRef<ShowId>,
@@ -62,6 +68,7 @@ impl Scanner {
continue;
}
};
#[expect(clippy::filetype_is_file, reason = "we only want regular files")]
if !filetype.is_file() {
yield Item {
path,
+33 -18
View File
@@ -19,28 +19,32 @@ use crate::scanner::{
CollectionScan, EpisodeScan, MediaRef, MovieScan, SeasonScan, ShowScan, collection, movie, show,
};
/// Regex for detecting a media folder.
static MEDIA_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
/// Regex for detecting a season folder.
static SEASON_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
/// A collection item
/// A collection item.
pub type Item = crate::Item<Scanner>;
/// The scanner for collections
/// The scanner for collections.
#[derive(Debug)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner {
/// A scanned collection
/// A scanned collection.
Collection(CollectionScan),
/// A scanned movie
/// A scanned movie.
Movie(MovieScan),
/// A scanned show
/// A scanned show.
Show(ShowScan),
/// A scanned episode
/// A scanned episode.
Season(SeasonScan),
/// A scanned episode
/// A scanned episode.
Episode(EpisodeScan),
}
impl From<collection::Scanner> for Scanner {
#[inline]
fn from(value: collection::Scanner) -> Self {
match value {
collection::Scanner::Collection(c) => Self::Collection(c),
@@ -53,6 +57,7 @@ impl From<collection::Scanner> for Scanner {
}
impl From<movie::Scanner> for Scanner {
#[inline]
fn from(value: movie::Scanner) -> Self {
match value {
movie::Scanner::Movie(m) => Self::Movie(m),
@@ -61,6 +66,7 @@ impl From<movie::Scanner> for Scanner {
}
impl From<show::Scanner> for Scanner {
#[inline]
fn from(value: show::Scanner) -> Self {
match value {
show::Scanner::Show(s) => Self::Show(s),
@@ -71,13 +77,13 @@ impl From<show::Scanner> for Scanner {
}
impl Scanner {
/// Helper function for stripping allowed numerical prefixes for sorting ("01 - ")
fn strip_numeric_prefix(original: &str) -> &str {
let mut s = original;
while let Some('0'..='9') = s.chars().next() {
s = &s[1..]
}
s.strip_prefix(" - ").unwrap_or(original)
/// Helper function for stripping allowed numerical prefixes for sorting ("01 - ").
#[inline]
fn strip_numeric_prefix(string: &str) -> &str {
string
.trim_start_matches(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'])
.strip_prefix(" - ")
.unwrap_or(string)
}
/// Detect the type of a folder and call the correct scanner. Use
@@ -85,6 +91,13 @@ impl Scanner {
/// - Collections
/// - Movies
/// - Shows
///
/// # Panics
/// If a static regex is invalid.
#[inline]
#[expect(clippy::allow_attributes, reason = "for tail_expr_drop_order")]
#[allow(tail_expr_drop_order, reason = "bug in stream! macro")]
#[expect(clippy::semicolon_if_nothing_returned, reason = "false positive")]
pub fn scan_detect_folder(
path: &Path,
parent: Option<MediaRef<CollectionId>>,
@@ -95,12 +108,14 @@ impl Scanner {
Show,
}
#[expect(clippy::panic, reason = "static regex string")]
let media_folder_re = MEDIA_FOLDER_REGEX.get_or_init(|| {
Regex::new(r"^[[[:alnum:]]' -]+ \([[:digit:]]+\)( \[[[:digit:]]+\])?$")
.unwrap_or_else(|err| panic!("regex is invalid: {err}"))
});
#[expect(clippy::panic, reason = "static regex string")]
let season_folder_re = SEASON_FOLDER_REGEX.get_or_init(|| {
Regex::new(r"^S[[:digit:]]+$").unwrap_or_else(|err| panic!("regex is invalid: {err}"))
Regex::new("^S[[:digit:]]+$").unwrap_or_else(|err| panic!("regex is invalid: {err}"))
});
stream!({
@@ -204,7 +219,7 @@ impl Scanner {
};
for await event in collection::Scanner::scan_collection(path, parent, id) {
yield event.map(|e| e.into());
yield event.map(Into::into);
}
}
MediaType::Movie => {
@@ -214,7 +229,7 @@ impl Scanner {
};
for await event in movie::Scanner::scan_movie(path, parent, id) {
yield event.map(|e| e.into());
yield event.map(Into::into);
}
}
MediaType::Show => {
@@ -224,7 +239,7 @@ impl Scanner {
};
for await event in show::Scanner::scan_show(path, parent, id) {
yield event.map(|e| e.into());
yield event.map(Into::into);
}
}
}
+11 -5
View File
@@ -1,5 +1,5 @@
//! The library scanner will scan an entire directory using the generic
//! scanner
//! scanner.
use std::ffi::OsStr;
use std::path::Path;
@@ -12,14 +12,20 @@ use tokio_stream::wrappers::ReadDirStream;
use crate::Error;
use crate::scanner::generic;
/// A library item
/// A library item.
pub type Item = crate::Item<generic::Scanner>;
/// The scanner for collections
/// The scanner for collections.
#[derive(Debug, Clone, Copy)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner {}
impl Scanner {
/// Scan a folder for a library
/// Scan a folder for a library.
#[inline]
#[expect(clippy::allow_attributes, reason = "for tail_expr_drop_order")]
#[allow(tail_expr_drop_order, reason = "bug in stream! macro")]
#[expect(clippy::semicolon_if_nothing_returned, reason = "false positive")]
pub fn scan_library(path: &Path) -> impl Stream<Item = Item> {
stream!({
let dirs = match fs::read_dir(path).await {
@@ -58,7 +64,7 @@ impl Scanner {
match path.extension().and_then(OsStr::to_str) {
Some(_) | None => {
yield Item {
path: path.to_owned(),
path: path.clone(),
event: Err(Error::UnexpectedFile),
};
}
+67 -31
View File
@@ -1,6 +1,6 @@
//! This module contains all of the filesystem scanner modules
//! This module contains all of the filesystem scanner modules.
//!
//! The most common scanner to use is [generic::Scanner] which will
//! The most common scanner to use is [`generic::Scanner`] which will
//! automatically detect and use the appropriate scanner.
use flix_model::id::{CollectionId, MovieId, ShowId};
@@ -18,82 +18,118 @@ pub mod episode;
pub mod season;
pub mod show;
/// A reference to a piece of media
/// A reference to a piece of media.
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
#[derive(Debug, Clone)]
pub enum MediaRef<ID> {
/// An explicit ID
/// An explicit ID.
Id(ID),
/// A filesystem slug
/// A filesystem slug.
Slug(String),
}
impl<ID> MediaRef<ID> {
/// Get the slug if it exists
/// Get the slug if it exists.
#[inline]
pub fn into_slug(self) -> Option<String> {
match self {
MediaRef::Id(_) => None,
MediaRef::Slug(slug) => Some(slug),
Self::Id(_) => None,
Self::Slug(slug) => Some(slug),
}
}
}
/// A scanned collection
/// A scanned collection.
#[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "it will be a breaking change when more information is scanned"
)]
pub struct CollectionScan {
/// The ID of the parent collection (if any)
/// The ID of the parent collection (if any).
pub parent_ref: Option<MediaRef<CollectionId>>,
/// The ID of the collection
/// The ID of the collection.
pub id_ref: MediaRef<CollectionId>,
/// The file name of the poster file
/// The file name of the poster file.
pub poster_file_name: Option<String>,
}
/// A scanned movie
/// A scanned movie.
#[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "it will be a breaking change when more information is scanned"
)]
pub struct MovieScan {
/// The ID of the parent collection (if any)
/// The ID of the parent collection (if any).
pub parent_ref: Option<MediaRef<CollectionId>>,
/// The ID of the movie
/// The ID of the movie.
pub id_ref: MediaRef<MovieId>,
/// The file name of the media file
/// The file name of the media file.
pub media_file_name: String,
/// The file name of the poster file
/// The file name of the poster file.
pub poster_file_name: Option<String>,
}
/// A scanned show
/// A scanned show.
#[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "it will be a breaking change when more information is scanned"
)]
pub struct ShowScan {
/// The ID of the parent collection (if any)
/// The ID of the parent collection (if any).
pub parent_ref: Option<MediaRef<CollectionId>>,
/// The ID of the show
/// The ID of the show.
pub id_ref: MediaRef<ShowId>,
/// The file name of the poster file
/// The file name of the poster file.
pub poster_file_name: Option<String>,
}
/// A scanned season
/// A scanned season.
#[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "it will be a breaking change when more information is scanned"
)]
pub struct SeasonScan {
/// The ID of the show this season belongs to
/// The ID of the show this season belongs to.
pub show_ref: MediaRef<ShowId>,
/// The season this episode belongs to
/// The season this episode belongs to.
pub season: SeasonNumber,
/// The file name of the poster file
/// The file name of the poster file.
pub poster_file_name: Option<String>,
}
/// A scanned episode
/// A scanned episode.
#[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "it will be a breaking change when more information is scanned"
)]
pub struct EpisodeScan {
/// The ID of the show this episode belongs to
/// The ID of the show this episode belongs to.
pub show_ref: MediaRef<ShowId>,
/// The season this episode belongs to
/// The season this episode belongs to.
pub season: SeasonNumber,
/// The number(s) of this episode
/// The number(s) of this episode.
pub episode: EpisodeNumbers,
/// The file name of the media file
/// The file name of the media file.
pub media_file_name: String,
/// The file name of the poster file
/// The file name of the poster file.
pub poster_file_name: Option<String>,
}
#[cfg(test)]
mod tests {
use super::{MediaRef, MovieId, library};
#[cfg(not(miri))]
#[test]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
fn use_fn() {
let tmp = tempfile::tempdir().expect("temporary directory exists");
drop(library::Scanner::scan_library(tmp.path()));
drop(MediaRef::Id(MovieId::from_raw(0)).into_slug());
}
}
+12 -5
View File
@@ -1,4 +1,4 @@
//! The movie scanner will scan a folder and exit
//! The movie scanner will scan a folder and exit.
use std::ffi::OsStr;
use std::path::Path;
@@ -14,17 +14,23 @@ use crate::Error;
use crate::macros::{is_image_extension, is_media_extension};
use crate::scanner::{MediaRef, MovieScan};
/// An movie item
/// An movie item.
pub type Item = crate::Item<Scanner>;
/// The scanner for movies
/// The scanner for movies.
#[derive(Debug)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner {
/// A scanned movie
/// A scanned movie.
Movie(MovieScan),
}
impl Scanner {
/// Scan a folder for a movie
/// Scan a folder for a movie.
#[inline]
#[expect(clippy::allow_attributes, reason = "for tail_expr_drop_order")]
#[allow(tail_expr_drop_order, reason = "bug in stream! macro")]
#[expect(clippy::semicolon_if_nothing_returned, reason = "false positive")]
pub fn scan_movie(
path: &Path,
parent_ref: Option<MediaRef<CollectionId>>,
@@ -60,6 +66,7 @@ impl Scanner {
continue;
}
};
#[expect(clippy::filetype_is_file, reason = "we only want regular files")]
if !filetype.is_file() {
yield Item {
path,
+16 -9
View File
@@ -1,10 +1,10 @@
//! The episode scanner will scan a folder and its children
//! The episode scanner will scan a folder and its children.
use std::ffi::OsStr;
use std::path::Path;
use flix_model::id::ShowId;
use flix_model::numbers::{EpisodeNumber, EpisodeNumbers, SeasonNumber};
use flix_model::numbers::{EpisodeNumbers, SeasonNumber};
use async_stream::stream;
use tokio::fs;
@@ -15,18 +15,21 @@ use crate::Error;
use crate::macros::is_image_extension;
use crate::scanner::{EpisodeScan, MediaRef, SeasonScan, episode};
/// A season item
/// A season item.
pub type Item = crate::Item<Scanner>;
/// The scanner for seasons
/// The scanner for seasons.
#[derive(Debug)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner {
/// A scanned season
/// A scanned season.
Season(SeasonScan),
/// A scanned episode
/// A scanned episode.
Episode(EpisodeScan),
}
impl From<episode::Scanner> for Scanner {
#[inline]
fn from(value: episode::Scanner) -> Self {
match value {
episode::Scanner::Episode(e) => Self::Episode(e),
@@ -35,7 +38,11 @@ impl From<episode::Scanner> for Scanner {
}
impl Scanner {
/// Scan a folder for a season and its episodes
/// Scan a folder for a season and its episodes.
#[inline]
#[expect(clippy::allow_attributes, reason = "for tail_expr_drop_order")]
#[allow(tail_expr_drop_order, reason = "bug in stream! macro")]
#[expect(clippy::semicolon_if_nothing_returned, reason = "false positive")]
pub fn scan_season(
path: &Path,
show_ref: MediaRef<ShowId>,
@@ -158,7 +165,7 @@ impl Scanner {
let Ok(episode_numbers) = e_str
.split('E')
.map(|s| s.parse::<EpisodeNumber>())
.map(str::parse)
.collect::<Result<Vec<_>, _>>()
else {
yield Item {
@@ -181,7 +188,7 @@ impl Scanner {
season_number,
episode_numbers,
) {
yield event.map(|e| e.into());
yield event.map(Into::into);
}
}
})
+15 -8
View File
@@ -1,4 +1,4 @@
//! The show scanner will scan a folder and its children
//! The show scanner will scan a folder and its children.
use std::ffi::OsStr;
use std::path::Path;
@@ -15,20 +15,23 @@ use crate::Error;
use crate::macros::is_image_extension;
use crate::scanner::{EpisodeScan, MediaRef, SeasonScan, ShowScan, season};
/// A show item
/// A show item.
pub type Item = crate::Item<Scanner>;
/// The scanner for shows
/// The scanner for shows.
#[derive(Debug)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner {
/// A scanned show
/// A scanned show.
Show(ShowScan),
/// A scanned season
/// A scanned season.
Season(SeasonScan),
/// A scanned episode
/// A scanned episode.
Episode(EpisodeScan),
}
impl From<season::Scanner> for Scanner {
#[inline]
fn from(value: season::Scanner) -> Self {
match value {
season::Scanner::Season(s) => Self::Season(s),
@@ -38,7 +41,11 @@ impl From<season::Scanner> for Scanner {
}
impl Scanner {
/// Scan a folder for a show and its seasons/episodes
/// Scan a folder for a show and its seasons/episodes.
#[inline]
#[expect(clippy::allow_attributes, reason = "for tail_expr_drop_order")]
#[allow(tail_expr_drop_order, reason = "bug in stream! macro")]
#[expect(clippy::semicolon_if_nothing_returned, reason = "false positive")]
pub fn scan_show(
path: &Path,
parent_ref: Option<MediaRef<CollectionId>>,
@@ -143,7 +150,7 @@ impl Scanner {
for await event in
season::Scanner::scan_season(&season_dir, id_ref.clone(), season_number)
{
yield event.map(|e| e.into());
yield event.map(Into::into);
}
}
})
+7 -5
View File
@@ -1,14 +1,16 @@
[package]
name = "flix-model"
version = "0.0.20"
license-file.workspace = true
version = "0.1.0"
description = "Core types for flix data"
repository = "https://github.com/QuantumShade/flix"
categories = []
repository = "https://git.skrundz.dev/quantumshade/flix"
keywords = ["flix"]
categories = ["multimedia"]
include = ["/src"]
publish = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs]
all-features = true
+29 -6
View File
@@ -1,26 +1,49 @@
//! This module contains types relating to flix media IDs
//! This module contains types relating to flix media IDs.
#![expect(clippy::module_name_repetitions, reason = "ID types end with Id")]
use seamantic::model::id::Id;
/// Type alias for the raw ID representation
/// Type alias for the raw ID representation.
pub use seamantic::model::id::SeaOrmRepr as RawId;
#[doc(hidden)]
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub enum Library {}
/// Type alias for a library ID
/// Type alias for a library ID.
pub type LibraryId = Id<Library>;
#[doc(hidden)]
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub enum Collection {}
/// Type alias for a collection ID
/// Type alias for a collection ID.
pub type CollectionId = Id<Collection>;
#[doc(hidden)]
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub enum Movie {}
/// Type alias for a movie ID
/// Type alias for a movie ID.
pub type MovieId = Id<Movie>;
#[doc(hidden)]
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub enum Show {}
/// Type alias for a show ID
/// Type alias for a show ID.
pub type ShowId = Id<Show>;
#[cfg(test)]
mod tests {
use super::{CollectionId, LibraryId, MovieId, ShowId};
#[test]
fn use_types() {
_ = CollectionId::from_raw(0);
_ = LibraryId::from_raw(0);
_ = MovieId::from_raw(0);
_ = ShowId::from_raw(0);
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! flix-model provides core types for flix data
//! flix-model provides core types for flix data.
#![cfg_attr(docsrs, feature(doc_cfg))]
+60 -26
View File
@@ -1,4 +1,4 @@
//! This module contains season and episode numbers and related errors
//! This module contains season and episode numbers and related errors.
use core::fmt;
use core::ops::RangeInclusive;
@@ -7,7 +7,7 @@ use std::collections::HashSet;
use seamantic::sea_orm;
/// Newtype for representing season numbers
/// Newtype for representing season numbers.
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, sea_orm::DeriveValueType,
)]
@@ -17,13 +17,16 @@ use seamantic::sea_orm;
pub struct SeasonNumber(u32);
impl SeasonNumber {
/// Create a `SeasonNumber` from an integer
pub fn new(value: u32) -> Self {
/// Create a [`SeasonNumber`] from an integer.
#[inline]
#[must_use]
pub const fn new(value: u32) -> Self {
Self(value)
}
}
impl fmt::Display for SeasonNumber {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
@@ -32,12 +35,13 @@ impl fmt::Display for SeasonNumber {
impl FromStr for SeasonNumber {
type Err = <u32 as FromStr>::Err;
#[inline]
fn from_str(s: &str) -> Result<Self, Self::Err> {
u32::from_str(s).map(Self)
}
}
/// Newtype for representing episode numbers
/// Newtype for representing episode numbers.
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, sea_orm::DeriveValueType,
)]
@@ -47,18 +51,23 @@ impl FromStr for SeasonNumber {
pub struct EpisodeNumber(u32);
impl EpisodeNumber {
/// Create an `EpisodeNumber` from an integer
pub fn new(value: u32) -> Self {
/// Create an [`EpisodeNumber`] from an integer.
#[inline]
#[must_use]
pub const fn new(value: u32) -> Self {
Self(value)
}
/// Get the underlying value
pub fn into_inner(self) -> u32 {
/// Get the underlying value.
#[inline]
#[must_use]
pub const fn into_inner(self) -> u32 {
self.0
}
}
impl fmt::Display for EpisodeNumber {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
@@ -67,33 +76,40 @@ impl fmt::Display for EpisodeNumber {
impl FromStr for EpisodeNumber {
type Err = <u32 as FromStr>::Err;
#[inline]
fn from_str(s: &str) -> Result<Self, Self::Err> {
u32::from_str(s).map(Self)
}
}
/// Potential errors when building EpisodeNumbers
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// There are no episodes
/// Potential errors when building [`EpisodeNumbers`].
#[derive(Debug, Clone, Copy, thiserror::Error)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to add new variants")]
pub enum EpisodeNumbersError {
/// There are no episodes.
#[error("zero episodes")]
Zero,
/// There are gaps in the episodes
/// There are gaps in the episodes.
#[error("noncontiguous episodes")]
Noncontiguous,
}
/// A wrapper for handling single and multi-episode entries
/// A wrapper for handling single and multi-episode entries.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[expect(
clippy::module_name_repetitions,
reason = "Episodes is not a good name"
)]
pub struct EpisodeNumbers(RangeInclusive<EpisodeNumber>);
impl TryFrom<&[EpisodeNumber]> for EpisodeNumbers {
type Error = Error;
type Error = EpisodeNumbersError;
#[inline]
fn try_from(value: &[EpisodeNumber]) -> Result<Self, Self::Error> {
match value {
[] => Err(Error::Zero),
[] => Err(EpisodeNumbersError::Zero),
[n] => Ok(Self(*n..=*n)),
_ => {
// min and max will always exist
@@ -102,12 +118,12 @@ impl TryFrom<&[EpisodeNumber]> for EpisodeNumbers {
let len = value.len();
if usize::try_from(max.0.saturating_sub(min.0).saturating_add(1)) != Ok(len) {
return Err(Error::Noncontiguous);
return Err(EpisodeNumbersError::Noncontiguous);
}
let set: HashSet<_> = value.iter().copied().collect();
if set.len() != len {
return Err(Error::Noncontiguous);
return Err(EpisodeNumbersError::Noncontiguous);
}
Ok(Self(min..=max))
@@ -117,27 +133,45 @@ impl TryFrom<&[EpisodeNumber]> for EpisodeNumbers {
}
impl EpisodeNumbers {
/// Create an [EpisodeNumbers] from a starting number and a count.
/// Create an [`EpisodeNumbers`] from a starting number and a count.
/// `count` should be zero for single episodes.
#[inline]
#[must_use]
pub fn new(start: EpisodeNumber, count: u8) -> Self {
Self(start..=EpisodeNumber(start.0.saturating_add(count.into())))
}
/// Get the range of episodes
pub fn as_range(&self) -> &RangeInclusive<EpisodeNumber> {
/// Get the range of episodes.
#[inline]
#[must_use]
pub const fn as_range(&self) -> &RangeInclusive<EpisodeNumber> {
&self.0
}
/// Render this [EpisodeNumbers] as a range. If only one episode is
/// is present it renders as `01`, if multiple it renders as `01-02`
/// Render this [`EpisodeNumbers`] as a range. If only one episode is
/// is present it renders as `01`, if multiple it renders as `01-02`.
#[inline]
#[must_use]
pub fn range_string(&self) -> String {
let start = self.0.start();
let end = self.0.end();
if start == end {
format!("{:02}", start)
format!("{start:02}")
} else {
format!("{:02}-{:02}", start, end)
format!("{start:02}-{end:02}")
}
}
}
#[cfg(test)]
mod tests {
use super::{EpisodeNumber, EpisodeNumbers, SeasonNumber};
#[test]
fn use_fn() {
_ = SeasonNumber::new(0);
let e = EpisodeNumber::new(0);
_ = EpisodeNumbers::new(e, 1);
}
}
+48 -13
View File
@@ -1,22 +1,26 @@
//! This module contains helper functions for normalizing media titles
//! This module contains helper functions for normalizing media titles.
use core::iter::Peekable;
use itertools::Itertools;
/// Return an iterator over the normalized words of a string.
/// - Alphanumeric
/// - Lowercase
/// - Replace `&` with `and`
/// - Collapse acronyms
///
/// # Panics
///
/// Panics if `input` is not ASCII.
fn split_normalized_words(input: &str) -> impl Iterator<Item = String> {
if !input.is_ascii() {
panic!("Input is not ASCII: {input}");
}
assert!(input.is_ascii(), "input is not ASCII: {input}");
input
.split_ascii_whitespace()
.map(|s| {
if s == "&" {
return "and".to_string();
return "and".to_owned();
}
let chars = s
@@ -37,6 +41,7 @@ fn split_normalized_words(input: &str) -> impl Iterator<Item = String> {
.filter(|part: &String| !part.is_empty() && part != "-")
}
/// Split out `a`, `an`, and `the` from the start of the string if it exists.
fn split_leading_article<I: Iterator<Item = String>>(iter: I) -> (Option<String>, Peekable<I>) {
let mut iter = iter.peekable();
match iter.peek().map(String::as_str) {
@@ -57,13 +62,15 @@ fn split_leading_article<I: Iterator<Item = String>>(iter: I) -> (Option<String>
/// # Panics
///
/// Panics if `input` is not ASCII.
#[inline]
#[must_use]
pub fn make_sortable_title(title: &str) -> String {
let words = split_normalized_words(title);
let (article, words) = split_leading_article(words);
let output = Itertools::intersperse(words, " ".to_string());
let output = Itertools::intersperse(words, " ".to_owned());
if let Some(article) = article {
output.chain([", ".to_string(), article]).collect()
output.chain([", ".to_owned(), article]).collect()
} else {
output.collect()
}
@@ -81,11 +88,13 @@ pub fn make_sortable_title(title: &str) -> String {
/// # Panics
///
/// Panics if `input` is not ASCII.
#[inline]
#[must_use]
pub fn make_fs_slug(title: &str) -> String {
let words = split_normalized_words(title);
let (_, words) = split_leading_article(words);
Itertools::intersperse(words, " ".to_string()).collect()
Itertools::intersperse(words, " ".to_owned()).collect()
}
/// Convert a media title and year to a folder name representable on filesystems
@@ -100,11 +109,13 @@ pub fn make_fs_slug(title: &str) -> String {
/// # Panics
///
/// Panics if `input` is not ASCII.
#[inline]
#[must_use]
pub fn make_fs_slug_year(title: &str, year: i32) -> String {
let words = split_normalized_words(title);
let (_, words) = split_leading_article(words);
Itertools::intersperse(words, " ".to_string())
Itertools::intersperse(words, " ".to_owned())
.chain([format!(" ({year})")])
.collect()
}
@@ -117,10 +128,12 @@ pub fn make_fs_slug_year(title: &str, year: i32) -> String {
/// assert_eq!(normalize_fs_name("Marvel's Agents of SHIELD (2013)"), "marvels agents of shield (2013)");
/// assert_eq!(normalize_fs_name("Avatar The Last Airbender (2005)"), "avatar the last airbender (2005)");
/// assert_eq!(normalize_fs_name("Cloak & Dagger (2018)"), "cloak and dagger (2018)");
#[inline]
#[must_use]
pub fn normalize_fs_name(input: &str) -> String {
let chars = input.split_ascii_whitespace().map(|s| {
if s == "&" {
return "and".to_string();
return "and".to_owned();
}
let chars = s
@@ -138,7 +151,7 @@ pub fn normalize_fs_name(input: &str) -> String {
chars.collect()
}
});
Itertools::intersperse(chars, " ".to_string()).collect()
Itertools::intersperse(chars, " ".to_owned()).collect()
}
/// Convert a media title to a url compatible string
@@ -153,11 +166,13 @@ pub fn normalize_fs_name(input: &str) -> String {
/// # Panics
///
/// Panics if `input` is not ASCII.
#[inline]
#[must_use]
pub fn make_web_slug(title: &str) -> String {
let words = split_normalized_words(title);
let (_, words) = split_leading_article(words);
Itertools::intersperse(words, "-".to_string()).collect()
Itertools::intersperse(words, "-".to_owned()).collect()
}
/// Convert a media title and year to a url compatible string
@@ -172,11 +187,31 @@ pub fn make_web_slug(title: &str) -> String {
/// # Panics
///
/// Panics if `input` is not ASCII.
#[inline]
#[must_use]
pub fn make_web_slug_year(title: &str, year: i32) -> String {
let words = split_normalized_words(title);
let (_, words) = split_leading_article(words);
Itertools::intersperse(words, "-".to_string())
Itertools::intersperse(words, "-".to_owned())
.chain([format!("-{year}")])
.collect()
}
#[cfg(test)]
mod tests {
use super::{
make_fs_slug, make_fs_slug_year, make_sortable_title, make_web_slug, make_web_slug_year,
normalize_fs_name,
};
#[test]
fn use_fn() {
drop(make_sortable_title(""));
drop(make_fs_slug(""));
drop(make_fs_slug_year("", 0));
drop(normalize_fs_name(""));
drop(make_web_slug(""));
drop(make_web_slug_year("", 0));
}
}
+8 -5
View File
@@ -1,14 +1,16 @@
[package]
name = "flix-tmdb"
version = "0.0.20"
license-file.workspace = true
version = "0.1.0"
description = "Clients and models for fetching data from TMDB"
repository = "https://github.com/QuantumShade/flix"
categories = []
repository = "https://git.skrundz.dev/quantumshade/flix"
keywords = ["flix"]
categories = ["multimedia"]
include = ["/src"]
publish = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs]
all-features = true
@@ -32,6 +34,7 @@ sea-orm = { workspace = true, optional = true }
[dev-dependencies]
serde_test = { workspace = true }
tempfile = { workspace = true }
[features]
default = []
+23 -11
View File
@@ -1,6 +1,6 @@
//! Collections API
//! Collections API.
use std::rc::Rc;
use alloc::sync::Arc;
use std::sync::RwLock;
use crate::api::exec_request;
@@ -10,26 +10,38 @@ use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request};
/// TMDB Collections API client
/// TMDB Collections API client.
#[derive(Debug)]
pub struct Client {
config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
/// The client configuration.
config: Arc<Config>,
/// The request cache.
cache: Arc<dyn Cache>,
/// The cache policy to use for requests.
policy: Arc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
/// Create a new client with the given configuration.
#[inline]
pub fn new(
config: Arc<Config>,
cache: Arc<dyn Cache>,
policy: Arc<RwLock<CachePolicy>>,
) -> Self {
Self {
config,
cache,
policy,
}
}
}
impl Client {
/// Fetch the details of the collection refered to by ID
/// Fetch the details of the collection refered to by ID.
///
/// # Errors
/// See [Error] for failure modes.
#[inline]
#[expect(clippy::impl_trait_in_params, reason = "turbofish is not expected")]
pub async fn get_details(
&self,
id: impl Into<CollectionId>,
+23 -11
View File
@@ -1,6 +1,6 @@
//! Episodes API
//! Episodes API.
use std::rc::Rc;
use alloc::sync::Arc;
use std::sync::RwLock;
use flix_model::numbers::{EpisodeNumber, SeasonNumber};
@@ -12,26 +12,38 @@ use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request};
/// TMDB Episodes API client
/// TMDB Episodes API client.
#[derive(Debug)]
pub struct Client {
config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
/// The client configuration.
config: Arc<Config>,
/// The request cache.
cache: Arc<dyn Cache>,
/// The cache policy to use for requests.
policy: Arc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
/// Create a new client with the given configuration.
#[inline]
pub fn new(
config: Arc<Config>,
cache: Arc<dyn Cache>,
policy: Arc<RwLock<CachePolicy>>,
) -> Self {
Self {
config,
cache,
policy,
}
}
}
impl Client {
/// Fetch the details of the episode refered to by ID, season number and episode number
/// Fetch the details of the episode refered to by ID, season number and episode number.
///
/// # Errors
/// See [Error] for failure modes.
#[inline]
#[expect(clippy::impl_trait_in_params, reason = "turbofish is not expected")]
pub async fn get_details(
&self,
id: impl Into<ShowId>,
+31 -17
View File
@@ -1,6 +1,5 @@
//! TMDB API clients
//! TMDB API clients.
use core::ops::Deref;
use core::time::Duration;
use std::sync::RwLock;
@@ -17,20 +16,26 @@ pub mod movies;
pub mod seasons;
pub mod shows;
/// A generic error wrapping Url and Reqwest errors
/// A generic error wrapping Url and Reqwest errors.
#[derive(Debug, thiserror::Error)]
#[expect(clippy::error_impl_error, reason = "Error is a good name here")]
#[expect(clippy::exhaustive_enums, reason = "unlikely to add new variants")]
pub enum Error {
/// Url error wrapper
/// Url error wrapper.
#[error("url parse error: {0}")]
Url(#[from] url::ParseError),
/// Reqwest error wrapper
/// Reqwest error wrapper.
#[error("reqwest error: {0}")]
Reqwest(#[from] reqwest::Error),
/// Json error wrapper
/// Json error wrapper.
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
}
/// Turn a [Config] and `path` into a [Request].
///
/// # Errors
/// See [Error] for failure modes.
fn make_request(config: &Config, path: &str, language: Option<&str>) -> Result<Request, Error> {
let url = config.base_url.join(path)?;
@@ -38,7 +43,7 @@ fn make_request(config: &Config, path: &str, language: Option<&str>) -> Result<R
header::AUTHORIZATION,
format!("Bearer {}", config.bearer_token),
);
if let Some(ref user_agent) = config.user_agent {
if let Some(user_agent) = &config.user_agent {
builder = builder.header(header::USER_AGENT, user_agent);
}
if let Some(language) = language {
@@ -48,22 +53,22 @@ fn make_request(config: &Config, path: &str, language: Option<&str>) -> Result<R
Ok(builder.build()?)
}
/// Execute a [Request] and deserialize the response.
///
/// # Errors
/// See [Error] for failure modes.
async fn exec_request<T: DeserializeOwned>(
config: &Config,
cache: &dyn Cache,
policy: &RwLock<CachePolicy>,
request: Request,
) -> Result<T, Error> {
let (read_cache, write_cache) = if let Ok(guard) = policy.read() {
match guard.deref() {
let (read_cache, write_cache) = policy.read().map_or((None, None), |guard| match *guard {
CachePolicy::None => (None, None),
CachePolicy::Full => (Some(cache), Some(cache)),
CachePolicy::Read => (Some(cache), None),
CachePolicy::Update => (None, Some(cache)),
}
} else {
(None, None)
};
});
let path = request.url().path().to_owned();
@@ -73,9 +78,9 @@ async fn exec_request<T: DeserializeOwned>(
response = cache.get(&path);
}
let needs_cache_write = response.is_none();
let response = match response {
Some(response) => response,
None => {
let response = if let Some(response) = response {
response
} else {
config
.limiter
.until_ready_with_jitter(Jitter::new(
@@ -90,7 +95,6 @@ async fn exec_request<T: DeserializeOwned>(
.error_for_status()?
.bytes()
.await?
}
};
// write to the cache if needed
@@ -102,3 +106,13 @@ async fn exec_request<T: DeserializeOwned>(
Ok(serde_json::from_slice(&response)?)
}
#[cfg(test)]
mod tests {
use super::Error;
#[test]
fn use_types() {
drop(Error::Url(url::ParseError::Overflow));
}
}
+23 -11
View File
@@ -1,6 +1,6 @@
//! Movies API
//! Movies API.
use std::rc::Rc;
use alloc::sync::Arc;
use std::sync::RwLock;
use crate::api::exec_request;
@@ -10,26 +10,38 @@ use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request};
/// TMDB Movies API client
/// TMDB Movies API client.
#[derive(Debug)]
pub struct Client {
config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
/// The client configuration.
config: Arc<Config>,
/// The request cache.
cache: Arc<dyn Cache>,
/// The cache policy to use for requests.
policy: Arc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
/// Create a new client with the given configuration.
#[inline]
pub fn new(
config: Arc<Config>,
cache: Arc<dyn Cache>,
policy: Arc<RwLock<CachePolicy>>,
) -> Self {
Self {
config,
cache,
policy,
}
}
}
impl Client {
/// Fetch the details of the movie refered to by ID
/// Fetch the details of the movie refered to by ID.
///
/// # Errors
/// See [Error] for failure modes.
#[inline]
#[expect(clippy::impl_trait_in_params, reason = "turbofish is not expected")]
pub async fn get_details(
&self,
id: impl Into<MovieId>,
+23 -11
View File
@@ -1,6 +1,6 @@
//! Seasons API
//! Seasons API.
use std::rc::Rc;
use alloc::sync::Arc;
use std::sync::RwLock;
use flix_model::numbers::SeasonNumber;
@@ -12,26 +12,38 @@ use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request};
/// TMDB Seasons API client
/// TMDB Seasons API client.
#[derive(Debug)]
pub struct Client {
config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
/// The client configuration.
config: Arc<Config>,
/// The request cache.
cache: Arc<dyn Cache>,
/// The cache policy to use for requests.
policy: Arc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
/// Create a new client with the given configuration.
#[inline]
pub fn new(
config: Arc<Config>,
cache: Arc<dyn Cache>,
policy: Arc<RwLock<CachePolicy>>,
) -> Self {
Self {
config,
cache,
policy,
}
}
}
impl Client {
/// Fetch the details of the season refered to by ID and season number
/// Fetch the details of the season refered to by ID and season number.
///
/// # Errors
/// See [Error] for failure modes.
#[inline]
#[expect(clippy::impl_trait_in_params, reason = "turbofish is not expected")]
pub async fn get_details(
&self,
id: impl Into<ShowId>,
+23 -11
View File
@@ -1,6 +1,6 @@
//! Shows API
//! Shows API.
use std::rc::Rc;
use alloc::sync::Arc;
use std::sync::RwLock;
use crate::api::exec_request;
@@ -10,26 +10,38 @@ use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request};
/// TMDB Shows API client
/// TMDB Shows API client.
#[derive(Debug)]
pub struct Client {
config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
/// The client configuration.
config: Arc<Config>,
/// The request cache.
cache: Arc<dyn Cache>,
/// The cache policy to use for requests.
policy: Arc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
/// Create a new client with the given configuration.
#[inline]
pub fn new(
config: Arc<Config>,
cache: Arc<dyn Cache>,
policy: Arc<RwLock<CachePolicy>>,
) -> Self {
Self {
config,
cache,
policy,
}
}
}
impl Client {
/// Fetch the details of the show refered to by ID
/// Fetch the details of the show refered to by ID.
///
/// # Errors
/// See [Error] for failure modes.
#[inline]
#[expect(clippy::impl_trait_in_params, reason = "turbofish is not expected")]
pub async fn get_details(
&self,
id: impl Into<ShowId>,
+33 -20
View File
@@ -1,58 +1,72 @@
//! Caching related types.
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use bytes::Bytes;
use redb::{Database, DatabaseError, ReadableDatabase, TableDefinition};
use redb::{Database, DatabaseError, ReadableDatabase as _, TableDefinition};
/// The client cache policy
/// The client cache policy.
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum CachePolicy {
/// Do not use a cache
/// Do not use a cache.
None,
/// Use and update the cache
/// Use and update the cache.
Full,
/// Use the cache but don't update it
/// Use the cache but don't update it.
Read,
/// Ignore the cache but update it
/// Ignore the cache but update it.
Update,
}
/// The trait representing a caching backend
pub trait Cache {
/// Get a cached value, or None
/// The trait representing a caching backend.
pub trait Cache: core::fmt::Debug + Send + Sync {
/// Get a cached value, or None.
fn get(&self, query: &str) -> Option<Bytes>;
/// Set a value in the cache
/// Set a value in the cache.
fn set(&self, query: &str, response: &Bytes);
}
const TABLE: TableDefinition<&str, (u64, &[u8])> = TableDefinition::new("tmdb_responses");
/// The [`TableDefinition`] for TMDB response data.
const TABLE: TableDefinition<'_, &str, (u64, &[u8])> = TableDefinition::new("tmdb_responses");
/// A [Cache] implementation using [redb] as the backend
/// A [Cache] implementation using [redb] as the backend.
#[derive(Debug)]
pub struct RedbCache {
/// The underlying database.
db: Database,
}
impl RedbCache {
/// Create/open a [redb] database at the path
/// Create/open a [redb] database at the path.
///
/// # Errors
/// Fails if creating/opening the database returns an error.
#[inline]
pub fn new(path: &Path) -> Result<Self, DatabaseError> {
Ok(Self {
db: Database::create(path)?,
})
}
/// Helper function allowing for `.ok()?`
/// Helper function allowing for `.ok()?`.
fn write(&self, timestamp: u64, query: &str, response: &Bytes) -> Option<()> {
let write_txn = self.db.begin_write().ok()?;
{
let mut table = write_txn.open_table(TABLE).ok()?;
drop(
table
.insert(query, (timestamp, response.iter().as_slice()))
.ok()?;
.ok()?,
);
}
write_txn.commit().ok()
}
}
impl Cache for RedbCache {
#[inline]
fn get(&self, query: &str) -> Option<Bytes> {
let read_txn = self.db.begin_read().ok()?;
let table = read_txn.open_table(TABLE).ok()?;
@@ -62,8 +76,7 @@ impl Cache for RedbCache {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
.map_or(0, |d| d.as_secs());
if now.saturating_sub(timestamp) >= 60 * 60 * 24 * 30 * 6 {
None
@@ -72,12 +85,12 @@ impl Cache for RedbCache {
}
}
#[inline]
fn set(&self, query: &str, response: &Bytes) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
.map_or(0, |d| d.as_secs());
self.write(now, query, response);
_ = self.write(now, query, response);
}
}
+57 -30
View File
@@ -1,44 +1,63 @@
use std::rc::Rc;
//! TMDB client.
use alloc::sync::Arc;
use std::sync::RwLock;
use crate::{Cache, CachePolicy, Config, api};
/// The primary client that references all other clients
/// The primary client that references all other clients.
#[derive(Debug)]
pub struct Client {
/// The collection API client.
collections: api::collections::Client,
/// The movie API client.
movies: api::movies::Client,
/// The show API client.
shows: api::shows::Client,
/// The season API client.
seasons: api::seasons::Client,
/// The episode API client.
episodes: api::episodes::Client,
cache_policy: Rc<RwLock<CachePolicy>>,
/// The cache policy to use for all clients.
cache_policy: Arc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Config, cache: Rc<dyn Cache>, cache_policy: CachePolicy) -> Self {
let config = Rc::new(config);
let cache_policy = Rc::new(RwLock::new(cache_policy));
/// Create a new client with the given configuration.
#[inline]
pub fn new(config: Config, cache: Arc<dyn Cache>, cache_policy: CachePolicy) -> Self {
let config = Arc::new(config);
let cache_policy = Arc::new(RwLock::new(cache_policy));
Self {
collections: api::collections::Client::new(
config.clone(),
cache.clone(),
cache_policy.clone(),
Arc::clone(&config),
Arc::clone(&cache),
Arc::clone(&cache_policy),
),
movies: api::movies::Client::new(config.clone(), cache.clone(), cache_policy.clone()),
shows: api::shows::Client::new(config.clone(), cache.clone(), cache_policy.clone()),
seasons: api::seasons::Client::new(config.clone(), cache.clone(), cache_policy.clone()),
episodes: api::episodes::Client::new(
config.clone(),
cache.clone(),
cache_policy.clone(),
movies: api::movies::Client::new(
Arc::clone(&config),
Arc::clone(&cache),
Arc::clone(&cache_policy),
),
shows: api::shows::Client::new(
Arc::clone(&config),
Arc::clone(&cache),
Arc::clone(&cache_policy),
),
seasons: api::seasons::Client::new(
Arc::clone(&config),
Arc::clone(&cache),
Arc::clone(&cache_policy),
),
episodes: api::episodes::Client::new(config, cache, Arc::clone(&cache_policy)),
cache_policy,
}
}
/// Modify the [CachePolicy]
/// Modify the [`CachePolicy`].
#[inline]
pub fn set_cache_policy(&self, new_policy: CachePolicy) {
match self.cache_policy.write() {
Ok(mut policy) => *policy = new_policy,
@@ -48,31 +67,39 @@ impl Client {
}
}
}
}
impl Client {
/// Access the Collections API
pub fn collections(&self) -> &api::collections::Client {
/// Access the Collections API.
#[inline]
#[must_use]
pub const fn collections(&self) -> &api::collections::Client {
&self.collections
}
/// Access the Movies API
pub fn movies(&self) -> &api::movies::Client {
/// Access the Movies API.
#[inline]
#[must_use]
pub const fn movies(&self) -> &api::movies::Client {
&self.movies
}
/// Access the Shows API
pub fn shows(&self) -> &api::shows::Client {
/// Access the Shows API.
#[inline]
#[must_use]
pub const fn shows(&self) -> &api::shows::Client {
&self.shows
}
/// Access the Seasons API
pub fn seasons(&self) -> &api::seasons::Client {
/// Access the Seasons API.
#[inline]
#[must_use]
pub const fn seasons(&self) -> &api::seasons::Client {
&self.seasons
}
/// Access the Episodes API
pub fn episodes(&self) -> &api::episodes::Client {
/// Access the Episodes API.
#[inline]
#[must_use]
pub const fn episodes(&self) -> &api::episodes::Client {
&self.episodes
}
}
+14 -8
View File
@@ -1,3 +1,5 @@
//! Client configuration.
use governor::clock::MonotonicClock;
use governor::state::{InMemoryState, NotKeyed};
use governor::{Quota, RateLimiter};
@@ -5,27 +7,31 @@ use nonzero_ext::nonzero;
use url::Url;
use url_macro::url;
/// The client configuration
/// The client configuration.
#[derive(Debug)]
#[expect(clippy::exhaustive_structs, reason = "must be constructed in full")]
pub struct Config {
/// The base URL of the API
/// The base URL of the API.
pub base_url: Url,
/// The reqwest client that is used for every request
/// The reqwest client that is used for every request.
pub client: reqwest::Client,
/// The rate limiter to use for the client
/// The rate limiter to use for the client.
pub limiter: RateLimiter<NotKeyed, InMemoryState, MonotonicClock>,
/// The bearer token for readonly access to the API
/// The bearer token for readonly access to the API.
pub bearer_token: String,
/// An optional user agent string to provide to the API
/// An optional user agent string to provide to the API.
pub user_agent: Option<String>,
}
impl Config {
/// Create a new configuration using the provided bearer token
/// Create a new configuration using the provided bearer token.
#[inline]
#[must_use]
pub fn new(bearer_token: String) -> Self {
Self {
base_url: url!("https://api.themoviedb.org"),
client: reqwest::Client::new(),
limiter: RateLimiter::direct(Quota::per_second(nonzero!(30u32))),
limiter: RateLimiter::direct(Quota::per_second(nonzero!(30_u32))),
bearer_token,
user_agent: None,
}
+20 -1
View File
@@ -1,7 +1,9 @@
//! flix-tmdb provides clients and models for fetching data from TMDB
//! flix-tmdb provides clients and models for fetching data from TMDB.
#![cfg_attr(docsrs, feature(doc_cfg))]
extern crate alloc;
pub mod api;
pub mod model;
@@ -13,3 +15,20 @@ pub use client::Client;
mod config;
pub use config::Config;
#[cfg(test)]
mod tests {
use alloc::sync::Arc;
use super::{CachePolicy, Client, Config, RedbCache};
#[cfg(not(miri))]
#[test]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
fn use_types() {
let tmp = tempfile::tempdir().expect("temporary directory exists");
let config = Config::new(String::new());
let cache = Arc::new(RedbCache::new(&tmp.path().join("cache.redb")).expect("redb"));
drop(Client::new(config, cache, CachePolicy::Full));
}
}
+31 -8
View File
@@ -1,25 +1,48 @@
//! TMDB collection model.
use super::id::{CollectionId, MovieId};
/// A deserialized Collection from the TMDB API
/// A deserialized Collection from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Collection {
/// The collection's TMDB ID
/// The collection's TMDB ID.
pub id: CollectionId,
/// The collection's title
/// The collection's title.
#[serde(rename = "name")]
pub title: String,
/// The collection's overview
/// The collection's overview.
pub overview: String,
/// The list of movies that are part of this collection
/// The list of movies that are part of this collection.
#[serde(rename = "parts")]
pub movies: Vec<Item>,
}
/// A deserialized collection item from the TMDB API
/// A deserialized collection item from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Item {
/// The movie's TMDB ID
/// The movie's TMDB ID.
pub id: MovieId,
/// The movie's title
/// The movie's title.
pub title: String,
}
#[cfg(test)]
mod tests {
use super::{Collection, CollectionId, Item, MovieId};
#[test]
fn use_types() {
drop(Collection {
id: CollectionId::from_raw(0),
title: String::new(),
overview: String::new(),
movies: Vec::new(),
});
drop(Item {
id: MovieId::from_raw(0),
title: String::new(),
});
}
}
+31 -7
View File
@@ -1,3 +1,5 @@
//! TMDB episode model.
use core::time::Duration;
use flix_model::numbers::EpisodeNumber;
@@ -7,22 +9,44 @@ use url::Url;
use super::{duration_from_minutes, still_url_from_path};
/// A deserialized Episode from the TMDB API
/// A deserialized Episode from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Episode {
/// The episode's number
/// The episode's number.
pub episode_number: EpisodeNumber,
/// The episode's title
/// The episode's title.
#[serde(rename = "name")]
pub title: String,
/// The episode's overview
/// The episode's overview.
pub overview: String,
/// The episode's air date
/// The episode's air date.
pub air_date: NaiveDate,
/// The episode's runtime
/// The episode's runtime.
#[serde(deserialize_with = "duration_from_minutes")]
pub runtime: Duration,
/// The episode's still path
/// The episode's still path.
#[serde(deserialize_with = "still_url_from_path")]
pub still_path: Option<Url>,
}
#[cfg(test)]
mod tests {
use core::time::Duration;
use chrono::NaiveDate;
use super::{Episode, EpisodeNumber};
#[test]
fn use_types() {
drop(Episode {
episode_number: EpisodeNumber::new(0),
title: String::new(),
overview: String::new(),
air_date: NaiveDate::default(),
runtime: Duration::default(),
still_path: None,
});
}
}
+63 -24
View File
@@ -1,4 +1,6 @@
//! Typed TMDB IDs
//! Typed TMDB IDs.
#![expect(clippy::module_name_repetitions, reason = "ID types end with Id")]
use core::cmp::Ordering;
use core::fmt;
@@ -10,21 +12,25 @@ use sea_orm::sea_query::{ArrayType, Nullable, ValueType, ValueTypeErr};
#[cfg(feature = "sea-orm")]
use sea_orm::{ColIdx, ColumnType, DbErr, QueryResult, TryFromU64, TryGetError, TryGetable, Value};
/// The internal representation used by TMDB
/// The internal representation used by TMDB.
pub type TmdbRepr = u32;
/// An opaque type representing a TMDB ID
/// An opaque type representing a TMDB ID.
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
#[repr(transparent)]
pub struct Id<T> {
/// Internal ID representation.
id: TmdbRepr,
/// Marker to indicate an invariant T.
#[serde(skip_serializing, default)]
_phantom: PhantomData<T>,
_phantom: PhantomData<fn(T) -> T>,
}
// Manual implementation since `T: Clone` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> Clone for Id<T> {
#[inline]
fn clone(&self) -> Self {
*self
}
@@ -34,31 +40,40 @@ impl<T> Clone for Id<T> {
impl<T> Copy for Id<T> {}
// Manual implementation since `T: PartialEq` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> PartialEq for Id<T> {
#[inline]
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
// Manual implementation since `T: Eq` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> Eq for Id<T> {}
// Manual implementation since `T: PartialOrd` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> PartialOrd for Id<T> {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
// Manual implementation since `T: Ord` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> Ord for Id<T> {
#[inline]
fn cmp(&self, other: &Self) -> Ordering {
self.id.cmp(&other.id)
}
}
// Manual implementation since `T: Hash` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> Hash for Id<T> {
#[inline]
fn hash<H: Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
@@ -66,6 +81,8 @@ impl<T> Hash for Id<T> {
impl<T> Id<T> {
/// Allows the conversion from a raw value to [Id], though the use is discouraged.
#[inline]
#[must_use]
pub fn from_raw(raw: TmdbRepr) -> Self {
Self {
id: raw,
@@ -74,13 +91,16 @@ impl<T> Id<T> {
}
/// Allows extracting the raw value, though the use is discouraged.
pub fn into_raw(self) -> TmdbRepr {
#[inline]
#[must_use]
pub const fn into_raw(self) -> TmdbRepr {
self.id
}
}
impl<T> fmt::Debug for Id<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Id")
.field("T", &core::any::type_name::<T>())
.field("id", &self.id)
@@ -89,7 +109,9 @@ impl<T> fmt::Debug for Id<T> {
}
#[cfg(feature = "sea-orm")]
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> ValueType for Id<T> {
#[inline]
fn try_from(v: Value) -> Result<Self, ValueTypeErr> {
<TmdbRepr as ValueType>::try_from(v).map(|id| Self {
id,
@@ -97,14 +119,17 @@ impl<T> ValueType for Id<T> {
})
}
#[inline]
fn type_name() -> String {
format!("Id<{}>", &core::any::type_name::<T>())
format!("Id<{}>", core::any::type_name::<T>())
}
#[inline]
fn array_type() -> ArrayType {
TmdbRepr::array_type()
}
#[inline]
fn column_type() -> ColumnType {
TmdbRepr::column_type()
}
@@ -112,13 +137,16 @@ impl<T> ValueType for Id<T> {
#[cfg(feature = "sea-orm")]
impl<T> From<Id<T>> for Value {
#[inline]
fn from(value: Id<T>) -> Self {
value.id.into()
}
}
#[cfg(feature = "sea-orm")]
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> TryGetable for Id<T> {
#[inline]
fn try_get_by<I: ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
TmdbRepr::try_get_by(res, index).map(|id| Self {
id,
@@ -129,6 +157,7 @@ impl<T> TryGetable for Id<T> {
#[cfg(feature = "sea-orm")]
impl<T> TryFromU64 for Id<T> {
#[inline]
fn try_from_u64(n: u64) -> Result<Self, DbErr> {
TmdbRepr::try_from_u64(n).map(|id| Self {
id,
@@ -139,6 +168,7 @@ impl<T> TryFromU64 for Id<T> {
#[cfg(feature = "sea-orm")]
impl<T> Nullable for Id<T> {
#[inline]
fn null() -> Value {
TmdbRepr::null()
}
@@ -146,34 +176,34 @@ impl<T> Nullable for Id<T> {
#[cfg(test)]
mod tests {
#[test]
#[cfg(feature = "sea-orm")]
fn test_sea_orm() {
#[expect(dead_code, reason = "structs test derive macros")]
mod test_seaorm {
use sea_orm::{
ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait,
EnumIter, PrimaryKeyTrait,
};
use super::Id;
use super::super::Id;
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "ids")]
pub struct Model {
#[expect(clippy::use_self, reason = "derive macros cause Self to be invalid")]
struct Model {
#[sea_orm(primary_key, auto_increment = false)]
id: Id<Model>,
nullable: Option<Id<Model>>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
#[allow(dead_code)]
#[derive(Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
enum Relation {}
}
#[test]
fn test_serde() {
fn serde() {
use super::Id;
let id: Id<()> = Id::from_raw(1234);
@@ -181,61 +211,70 @@ mod tests {
}
}
/// Type alias for the raw ID representation
pub use self::TmdbRepr as RawId;
#[doc(hidden)]
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum Collection {}
/// Type alias for a collection ID
/// Type alias for a collection ID.
pub type CollectionId = Id<Collection>;
impl From<CollectionId> for flix_model::id::CollectionId {
#[inline]
fn from(value: CollectionId) -> Self {
Self::from_raw(value.into_raw().into())
}
}
impl TryFrom<flix_model::id::CollectionId> for CollectionId {
type Error = <RawId as TryFrom<flix_model::id::RawId>>::Error;
type Error = <TmdbRepr as TryFrom<flix_model::id::RawId>>::Error;
#[inline]
fn try_from(value: flix_model::id::CollectionId) -> Result<Self, Self::Error> {
value.into_raw().try_into().map(Self::from_raw)
}
}
#[doc(hidden)]
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum Movie {}
/// Type alias for a movie ID
/// Type alias for a movie ID.
pub type MovieId = Id<Movie>;
impl From<MovieId> for flix_model::id::MovieId {
#[inline]
fn from(value: MovieId) -> Self {
Self::from_raw(value.into_raw().into())
}
}
impl TryFrom<flix_model::id::MovieId> for MovieId {
type Error = <RawId as TryFrom<flix_model::id::RawId>>::Error;
type Error = <TmdbRepr as TryFrom<flix_model::id::RawId>>::Error;
#[inline]
fn try_from(value: flix_model::id::MovieId) -> Result<Self, Self::Error> {
value.into_raw().try_into().map(Self::from_raw)
}
}
#[doc(hidden)]
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum Show {}
/// Type alias for a show ID
/// Type alias for a show ID.
pub type ShowId = Id<Show>;
impl From<ShowId> for flix_model::id::ShowId {
#[inline]
fn from(value: ShowId) -> Self {
Self::from_raw(value.into_raw().into())
}
}
impl TryFrom<flix_model::id::ShowId> for ShowId {
type Error = <RawId as TryFrom<flix_model::id::RawId>>::Error;
type Error = <TmdbRepr as TryFrom<flix_model::id::RawId>>::Error;
#[inline]
fn try_from(value: flix_model::id::ShowId) -> Result<Self, Self::Error> {
value.into_raw().try_into().map(Self::from_raw)
}
+13 -9
View File
@@ -1,9 +1,9 @@
//! Deserializable types from the TMDB API
//! Deserializable types from the TMDB API.
use core::str::FromStr;
use core::str::FromStr as _;
use core::time::Duration;
use serde::{Deserialize, Deserializer};
use serde::{Deserialize as _, Deserializer};
use url::Url;
pub mod id;
@@ -20,14 +20,22 @@ pub use movie::*;
pub use season::*;
pub use show::*;
/// Deserializer for converting integer minutes to a [Duration].
///
/// # Errors
/// Fails if the value being deserialized isn't a [u64].
fn duration_from_minutes<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let minutes = u64::deserialize(deserializer).unwrap_or(0);
let minutes = u64::deserialize(deserializer)?;
Ok(Duration::from_secs(minutes.saturating_mul(60)))
}
/// Deserializer for converting a string to a [Url].
///
/// # Errors
/// Fails if the value being deserialized isn't a [`&str`].
fn still_url_from_path<'de, D>(deserializer: D) -> Result<Option<Url>, D::Error>
where
D: Deserializer<'de>,
@@ -38,10 +46,6 @@ where
let path = Option::<&str>::deserialize(deserializer)?;
Ok(path.and_then(|path| {
Url::from_str(&format!(
"{}{}{}",
TMDB_IMAGE_BASE, TMDB_IMAGE_QUALITY, path
))
.ok()
Url::from_str(&format!("{TMDB_IMAGE_BASE}{TMDB_IMAGE_QUALITY}{path}")).ok()
}))
}
+40 -11
View File
@@ -1,3 +1,5 @@
//! TMDB movie model.
use core::time::Duration;
use chrono::NaiveDate;
@@ -5,33 +7,35 @@ use chrono::NaiveDate;
use super::duration_from_minutes;
use super::id::{CollectionId, MovieId};
/// A deserialized Movie from the TMDB API
/// A deserialized Movie from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Movie {
/// The movie's TMDB ID
/// The movie's TMDB ID.
pub id: MovieId,
/// The movie's collection, if it exists
/// The movie's collection, if it exists.
#[serde(rename = "belongs_to_collection")]
pub collection: Option<InCollection>,
/// The movie's title
/// The movie's title.
pub title: String,
/// The movie's tagline
/// The movie's tagline.
pub tagline: String,
/// The movie's overview
/// The movie's overview.
pub overview: String,
/// The movie's release date
/// The movie's release date.
pub release_date: NaiveDate,
/// The movie's runtime
/// The movie's runtime.
#[serde(deserialize_with = "duration_from_minutes")]
pub runtime: Duration,
}
/// A deserialized movie's collection from the TMDB API
/// A deserialized movie's collection from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct InCollection {
/// The collection's TMDB ID
/// The collection's TMDB ID.
pub id: CollectionId,
/// The collection's title
/// The collection's title.
#[serde(rename = "name")]
pub title: String,
}
@@ -43,3 +47,28 @@ pub struct InCollection {
// TODO: Company
// pub companies: Vec<Company>
// where: struct Company { id, name }
#[cfg(test)]
mod tests {
use core::time::Duration;
use chrono::NaiveDate;
use super::{CollectionId, InCollection, Movie, MovieId};
#[test]
fn use_types() {
drop(Movie {
id: MovieId::from_raw(0),
collection: Some(InCollection {
id: CollectionId::from_raw(0),
title: String::new(),
}),
title: String::new(),
tagline: String::new(),
overview: String::new(),
release_date: NaiveDate::default(),
runtime: Duration::default(),
});
}
}
+31 -8
View File
@@ -1,23 +1,46 @@
//! TMDB season model.
use chrono::NaiveDate;
use flix_model::numbers::SeasonNumber;
/// A deserialized Season from the TMDB API
/// A deserialized Season from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Season {
/// The season's number
/// The season's number.
pub season_number: SeasonNumber,
/// The season's title
/// The season's title.
#[serde(rename = "name")]
pub title: String,
/// The season's overview
/// The season's overview.
pub overview: String,
/// The season's air date
/// The season's air date.
pub air_date: NaiveDate,
/// The number of episodes in this season
/// The number of episodes in this season.
pub episodes: Vec<FakeEpisode>,
}
/// A placeholder struct for parsing the episodes list for a season
#[derive(Debug, Clone, serde::Deserialize)]
/// A placeholder struct for parsing the episodes list for a season.
#[derive(Debug, Clone, Copy, serde::Deserialize)]
#[non_exhaustive]
#[expect(clippy::empty_structs_with_brackets, reason = "might add fields later")]
pub struct FakeEpisode {}
#[cfg(test)]
mod tests {
use chrono::NaiveDate;
use super::{Season, SeasonNumber};
#[test]
fn use_types() {
drop(Season {
season_number: SeasonNumber::new(0),
title: String::new(),
overview: String::new(),
air_date: NaiveDate::default(),
episodes: Vec::new(),
});
}
}
+33 -9
View File
@@ -1,26 +1,29 @@
//! TMDB show model.
use chrono::NaiveDate;
use super::id::ShowId;
/// A deserialized Show from the TMDB API
/// A deserialized Show from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Show {
/// The show's TMDB ID
/// The show's TMDB ID.
pub id: ShowId,
/// The show's title
/// The show's title.
#[serde(rename = "name")]
pub title: String,
/// The show's tagline
/// The show's tagline.
pub tagline: String,
/// The show's overview
/// The show's overview.
pub overview: String,
/// The show's first air date
/// The show's first air date.
pub first_air_date: NaiveDate,
/// The show's last air date
/// The show's last air date.
pub last_air_date: NaiveDate,
/// The total number of episodes in this show
/// The total number of episodes in this show.
pub number_of_episodes: u32,
/// The number of seasons in this show
/// The number of seasons in this show.
pub number_of_seasons: u32,
}
@@ -35,3 +38,24 @@ pub struct Show {
// TODO: Company
// pub companies: Vec<Company>
// where: struct Company { id, name }
#[cfg(test)]
mod tests {
use chrono::NaiveDate;
use super::{Show, ShowId};
#[test]
fn use_types() {
drop(Show {
id: ShowId::from_raw(0),
title: String::new(),
tagline: String::new(),
overview: String::new(),
first_air_date: NaiveDate::default(),
last_air_date: NaiveDate::default(),
number_of_episodes: 0,
number_of_seasons: 0,
});
}
}
+44
View File
@@ -0,0 +1,44 @@
set minimum-version := '1.55.0'
set default-list
alias update := update-min
all:
just all-min
just all-max
all-min: update-min build lint test example doc format
all-max: update-max build lint test example doc format
update-min:
cargo +nightly update -Z direct-minimal-versions
update-max:
cargo update
build:
cargo hack --feature-powerset build
lint:
cargo hack --feature-powerset clippy -- -D warnings
cargo hack --feature-powerset clippy --tests -- -D warnings
test:
cargo hack --feature-powerset test
cargo +1.94 hack --feature-powerset test
cargo +nightly hack --feature-powerset miri test
example:
# cargo run --example=<example> --features=<req_features>
doc *args:
RUSTDOCFLAGS="--cfg docsrs -D warnings" cargo +nightly doc \
--workspace --all-features --document-private-items \
--lib --bins --no-deps --locked \
{{ args }}
format:
cargo fmt --check
install:
cargo install --path crates/cli
cargo install --path crates/cli-mux
+1 -1
View File
@@ -1,2 +1,2 @@
[toolchain]
channel = "stable"
channel = "1.98"