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 !.zed/settings.json
/target !.gitignore
# Flix !/CONTRIBUTING.md
flix.db !/LICENSE.md
flix.redb !/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 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": { "lsp": {
"just-lsp": {
"initialization_options": {
"formatting": {
"indentation": "\t",
},
},
},
"rust-analyzer": { "rust-analyzer": {
"initialization_options": { "initialization_options": {
"imports": { "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] [workspace]
resolver = "2" resolver = "3"
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
license-file = "LICENSE.md"
edition = "2024" edition = "2024"
rust-version = "1.89.0" rust-version = "1.94"
license-file = "LICENSE.md"
publish = false
[workspace.dependencies] [workspace.dependencies]
flix = { path = "crates/flix", 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.0.20", default-features = false } flix-cli = { path = "crates/cli", version = "=0.1.0", default-features = false }
flix-db = { path = "crates/db", version = "=0.0.20", default-features = false } flix-db = { path = "crates/db", version = "=0.1.0", default-features = false }
flix-fs = { path = "crates/fs", version = "=0.0.20", default-features = false } flix-fs = { path = "crates/fs", version = "=0.1.0", default-features = false }
flix-model = { path = "crates/model", version = "=0.0.20", default-features = false } flix-model = { path = "crates/model", version = "=0.1.0", default-features = false }
flix-mux = { path = "crates/cli-mux", version = "=0.0.20", default-features = false } flix-mux = { path = "crates/cli-mux", version = "=0.1.0", default-features = false }
flix-tmdb = { path = "crates/tmdb", version = "=0.0.20", default-features = false } flix-tmdb = { path = "crates/tmdb", version = "=0.1.0", default-features = false }
seamantic = { version = "^0.0.14", default-features = false } anyhow = { version = "^1.0.87", default-features = false }
async-stream = { version = "^0.3.5", default-features = false }
sea-orm = { version = "=2.0.0-rc.38", default-features = false } bytes = { version = "^1.7.1", default-features = false }
sea-orm-migration = { version = "=2.0.0-rc.38", default-features = false } chrono = { version = "^0.4.40", default-features = false }
clap = { version = "^4.3.2", default-features = false }
anyhow = { version = "^1", default-features = false } console = { version = "^0.16.0", default-features = false }
async-stream = { version = "^0.3", default-features = false } either = { version = "^1.6.1", default-features = false }
bytes = { version = "^1", default-features = false } governor = { version = "^0.10.0", default-features = false }
chrono = { version = "^0.4", default-features = false } indicatif = { version = "^0.18.0", default-features = false }
clap = { version = "^4", default-features = false } itertools = { version = "^0.15.0", default-features = false }
console = { version = "^0.16", default-features = false } nonzero_ext = { version = "^0.3.0", default-features = false }
dialoguer = { version = "^0.12", default-features = false } redb = { version = "^4.0.0", default-features = false }
either = { version = "^1", default-features = false } regex = { version = "^1.11.2", default-features = false }
futures = { version = "^0.3", default-features = false } reqwest = { version = "^0.13.0", default-features = false }
governor = { version = "^0.10", default-features = false } sea-orm = { version = "^2.0.1", default-features = false }
indicatif = { version = "^0.18", default-features = false } sea-orm-migration = { version = "^2.0.0", default-features = false }
itertools = { version = "^0.14", default-features = false } seamantic = { version = "^1.0.1", default-features = false }
nonzero_ext = { version = "^0.3", default-features = false } serde = { version = "^1.0.225", default-features = false }
redb = { version = "^4", default-features = false } serde_json = { version = "^1.0.142", default-features = false }
regex = { version = "^1", default-features = false } serde_test = { version = "^1.0.0", default-features = false }
reqwest = { version = "^0.13", default-features = false } tempfile = { version = "^3.0.0", default-features = false }
serde = { version = "^1", default-features = false } thiserror = { version = "^2.0.18", default-features = false }
serde_json = { version = "^1", default-features = false } tokio = { version = "^1.48.0", default-features = false }
serde_test = { version = "^1", default-features = false } tokio-stream = { version = "^0.1.8", default-features = false }
thiserror = { version = "^2", default-features = false } toml = { version = "^1.0.0", default-features = false }
tokio = { version = "^1", default-features = false } tracing = { version = "^0.1.37", default-features = false }
tokio-stream = { version = "^0.1", default-features = false } tracing-subscriber = { version = "^0.3.17", default-features = false }
toml = { version = "^1", default-features = false } url = { version = "^2.4.0", default-features = false }
tracing = { version = "^0.1", default-features = false } url-macro = { version = "^0.2.0", default-features = false }
tracing-subscriber = { version = "^0.3", default-features = false } walkdir = { version = "^2.0.0", default-features = false }
url = { version = "^2", default-features = false }
url-macro = { version = "^0.2", default-features = false }
walkdir = { version = "^2", default-features = false }
[workspace.lints.clippy] [workspace.lints.clippy]
arithmetic_side_effects = "forbid" cargo = { level = "warn", priority = -1 }
as_conversions = "forbid" complexity = { level = "warn", priority = -1 }
checked_conversions = "forbid" correctness = { level = "deny", priority = -1 }
default_union_representation = "forbid" perf = { level = "warn", priority = -1 }
expect_used = "forbid" style = { level = "warn", priority = -1 }
indexing_slicing = "forbid" suspicious = { level = "warn", priority = -1 }
integer_division = "forbid"
integer_division_remainder_used = "forbid" absolute-paths = "allow"
transmute_undefined_repr = "forbid" alloc-instead-of-core = "warn"
unchecked_time_subtraction = "forbid" allow-attributes = "warn"
unwrap_used = "forbid" 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] [workspace.lints.rust]
arithmetic_overflow = "forbid" deprecated-safe = "forbid"
missing_docs = "forbid" future-incompatible = "deny"
unsafe_code = "forbid" keyword-idents = "deny"
unused_doc_comments = "forbid" 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] [profile.release]
opt-level = 3 opt-level = 3
+13 -11
View File
@@ -2,18 +2,20 @@
Libraries and tools for dealing with media metadata Libraries and tools for dealing with media metadata
## Various builds ## Development
- build: `cargo hack --feature-powerset build` Run the full validation suite before committing:
- clippy: `cargo hack --feature-powerset clippy -- -D warnings`
- test: `cargo hack --feature-powerset test` ```sh
- test old: `cargo +1.89 hack --feature-powerset test` just all
- fmt: `cargo fmt --check` just install
- docs: `RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --all-features` ```
- install: `cargo install --path crates/cli`
- install: `cargo install --path crates/cli-mux` ##### Publish
- semver: `cargo semver-checks --all-features`
- publish: `cargo publish --dry-run --workspace` - `cargo semver-checks --all-features`
- `cargo publish --dry-run -p <crate>`
- `cargo publish --dry-run --workspace`
## Building flix.db ## Building flix.db
+9 -24
View File
@@ -1,14 +1,16 @@
[package] [package]
name = "flix-mux" name = "flix-mux"
version = "0.0.20" version = "0.1.0"
license-file.workspace = true
description = "CLI for bulk media muxing" description = "CLI for bulk media muxing"
repository = "https://github.com/QuantumShade/flix" repository = "https://git.skrundz.dev/quantumshade/flix"
categories = ["command-line-utilities"] keywords = ["flix"]
categories = ["command-line-utilities", "multimedia"]
include = ["/src"]
publish = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs] [package.metadata.docs.rs]
all-features = true all-features = true
@@ -31,27 +33,10 @@ clap = { workspace = true, features = [
"usage", "usage",
] } ] }
console = { workspace = true } console = { workspace = true }
dialoguer = { workspace = true }
indicatif = { workspace = true } indicatif = { workspace = true }
serde = { workspace = true, features = ["derive", "std"] } serde = { workspace = true, features = ["derive", "std"] }
serde_json = { workspace = true, features = ["alloc"] } serde_json = { workspace = true, features = ["alloc"] }
walkdir = { workspace = true } walkdir = { workspace = true }
[lints.clippy] [lints]
arithmetic_side_effects = "deny" workspace = true
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"
+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; use crate::parser::Selector;
#[derive(Parser)] /// Command line argument parser struct.
#[derive(Debug, clap::Parser)]
#[command(version, about, long_about = None)] #[command(version, about, long_about = None)]
pub struct Cli { pub(crate) struct Args {
/// Dry run and print commands /// Dry run and print commands.
#[arg(short, long)] #[arg(short, long)]
dry_run: bool, dry_run: bool,
/// Stream selectors /// Stream selectors.
#[arg( #[arg(
short, short,
long, long,
@@ -21,29 +22,38 @@ pub struct Cli {
)] )]
selectors: Vec<Selector>, selectors: Vec<Selector>,
/// The path to the directory to scan /// The path to the directory to scan.
#[arg(value_name = "DIR")] #[arg(value_name = "DIR")]
scan_dir: PathBuf, scan_dir: PathBuf,
} }
impl Cli { impl Args {
pub fn is_dry(&self) -> bool { /// True if the user requested a dry run.
pub(crate) const fn is_dry_run(&self) -> bool {
self.dry_run self.dry_run
} }
pub fn selectors(&self) -> &[Selector] { /// Returns a slice of provided [Selector].
pub(crate) fn selectors(&self) -> &[Selector] {
&self.selectors &self.selectors
} }
pub fn scan_dir_path(&self) -> PathBuf { /// Get the path to scan.
fn expect_home_dir() -> PathBuf { ///
#[allow(clippy::expect_used)] /// # Panics
std::env::home_dir().expect("you do not have a home directory") /// 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(
match self.scan_dir.strip_prefix("~/") { |_| self.scan_dir.clone(),
Ok(path) => expect_home_dir().join(path), |path| {
Err(_) => self.scan_dir.to_owned(), #[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 core::time::Duration;
use clap::Parser; use clap::Parser as _;
use console::style; use console::style;
use indicatif::{HumanBytes, ProgressBar, ProgressStyle}; use indicatif::{HumanBytes, ProgressBar, ProgressStyle};
@@ -16,14 +16,17 @@ mod parser;
mod probe; mod probe;
mod scan; 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() { fn main() {
let cli = cli::Cli::parse(); let cli = cli::Args::parse();
let (files, size) = scan_directory( let (files, size) = scan_directory(
&cli.scan_dir_path(), &cli.scan_dir_path(),
|| { || {
let progress = ProgressBar::new_spinner(); 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.enable_steady_tick(Duration::from_millis(50));
progress progress
}, },
@@ -31,7 +34,8 @@ fn main() {
|len| { |len| {
let progress = ProgressBar::new(u64::try_from(len).unwrap_or(0)); let progress = ProgressBar::new(u64::try_from(len).unwrap_or(0));
progress.set_style( 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})") ProgressStyle::with_template("[{elapsed_precise}] {wide_bar} {pos}/{len} ({msg})")
.expect("static template"), .expect("static template"),
); );
@@ -40,19 +44,20 @@ fn main() {
|progress| progress.inc(1), |progress| progress.inc(1),
|progress| progress.finish_and_clear(), |progress| progress.finish_and_clear(),
|progress, msg| { |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)); println!("Found {} files ({})", files.len(), HumanBytes(size));
mux_files( mux_files(
cli.is_dry(), cli.is_dry_run(),
&files, &files,
cli.selectors(), cli.selectors(),
|len| { |len| {
let progress = ProgressBar::new(u64::try_from(len).unwrap_or(0)); let progress = ProgressBar::new(u64::try_from(len).unwrap_or(0));
progress.set_style( 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})") ProgressStyle::with_template("[{elapsed_precise}] {wide_bar} {pos}/{len} ({msg})")
.expect("static template"), .expect("static template"),
); );
@@ -61,6 +66,6 @@ fn main() {
}, },
|progress| progress.inc(1), |progress| progress.inc(1),
|progress| progress.finish_with_message("done"), |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 std::path::PathBuf;
use serde::Deserialize; use serde::Deserialize;
/// Represents a file that was scanned.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MediaFile { pub(crate) struct MediaFile {
/// The path to the file.
pub path: PathBuf, pub path: PathBuf,
/// The size of the file.
pub byte_size: u64, pub byte_size: u64,
/// The streams in the file.
pub streams: Streams, pub streams: Streams,
} }
/// Represents the collection of streams found in a media file.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Streams { pub(crate) struct Streams {
/// The video streams.
pub video: Vec<VideoStream>, pub video: Vec<VideoStream>,
/// The audio streams.
pub audio: Vec<AudioStream>, pub audio: Vec<AudioStream>,
/// The subtitle streams.
pub subtitle: Vec<SubtitleStream>, 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; const FF_TYPE_NAME: &str;
} }
/// Represents an individual video stream in a media file.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct VideoStream { pub(crate) struct VideoStream {
/// The name of the video codec.
codec_name: String, codec_name: String,
/// Additional tags on the stream.
tags: Option<VideoTags>, tags: Option<VideoTags>,
} }
/// Represents tags on a video stream.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct VideoTags { pub(crate) struct VideoTags {
/// The language of the stream.
language: Option<String>, language: Option<String>,
} }
impl VideoStream { impl VideoStream {
pub fn codec(&self) -> &str { /// Get the video codec name.
pub(crate) fn codec(&self) -> &str {
&self.codec_name &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() self.tags.as_ref()?.language.as_deref()
} }
} }
@@ -45,23 +64,30 @@ impl FFStream for VideoStream {
const FF_TYPE_NAME: &str = "v"; const FF_TYPE_NAME: &str = "v";
} }
/// Represents an individual audio stream in a media file.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct AudioStream { pub(crate) struct AudioStream {
/// The name of the audio codec.
codec_name: String, codec_name: String,
/// Additional tags on the stream.
tags: Option<AudioTags>, tags: Option<AudioTags>,
} }
/// Represents tags on an audio stream.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct AudioTags { pub(crate) struct AudioTags {
/// The language of the stream.
language: Option<String>, language: Option<String>,
} }
impl AudioStream { impl AudioStream {
pub fn codec(&self) -> &str { /// Get the audio codec name.
pub(crate) fn codec(&self) -> &str {
&self.codec_name &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() self.tags.as_ref()?.language.as_deref()
} }
} }
@@ -70,28 +96,37 @@ impl FFStream for AudioStream {
const FF_TYPE_NAME: &str = "a"; const FF_TYPE_NAME: &str = "a";
} }
/// Represents an individual subtitle stream in a media file.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct SubtitleStream { pub(crate) struct SubtitleStream {
/// The name of the subtitle codec.
codec_name: String, codec_name: String,
/// Additional tags on the stream.
tags: Option<SubtitleTags>, tags: Option<SubtitleTags>,
} }
/// Represents tags on a subtitle stream.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct SubtitleTags { pub(crate) struct SubtitleTags {
/// The language of the stream.
language: Option<String>, language: Option<String>,
/// The title of the stream.
title: Option<String>, title: Option<String>,
} }
impl SubtitleStream { impl SubtitleStream {
pub fn codec(&self) -> &str { /// Get the subtitle codec name.
pub(crate) fn codec(&self) -> &str {
&self.codec_name &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() 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() self.tags.as_ref()?.title.as_deref()
} }
} }
+77 -39
View File
@@ -1,3 +1,5 @@
//! Wrapper around `ffmpeg` to mux media files.
use std::process::Command; use std::process::Command;
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
@@ -5,14 +7,19 @@ use anyhow::{Context as _, Result};
use crate::model::MediaFile; use crate::model::MediaFile;
use crate::parser::{Matcher, Selector, StreamFlag, StreamType}; use crate::parser::{Matcher, Selector, StreamFlag, StreamType};
/// Helper struct for tracking which output index is currently available.
#[derive(Default)] #[derive(Default)]
struct OutputIndex { struct OutputIndex {
/// The next free video index.
video: usize, video: usize,
/// The next free audio index.
audio: usize, audio: usize,
/// The next free subtitle index.
subtitle: usize, 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, dry_run: bool,
files: &[MediaFile], files: &[MediaFile],
selectors: &[Selector], selectors: &[Selector],
@@ -24,14 +31,20 @@ pub fn mux_files<T>(
let mut progress = fixed_length_start(files.len()); let mut progress = fixed_length_start(files.len());
for file in files { for file in files {
if let Err(err) = mux(dry_run, file, selectors) { 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_update(&mut progress);
} }
fixed_length_end(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<()> { fn mux(dry_run: bool, file: &MediaFile, selectors: &[Selector]) -> Result<()> {
let mut command = Command::new("ffmpeg"); let mut command = Command::new("ffmpeg");
let mut command = command.args(["-v", "error"]); 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()); command = command.arg(file.path.as_os_str());
for selector in selectors { for selector in selectors {
command = command.args( command = command.args(make_map_args(file, selector).with_context(|| {
make_map_args(file, selector) let info = if dry_run {
.with_context(|| format!("Failed to mux {:?}", file.path))?, 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"]); 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(); let mut ouput_index = OutputIndex::default();
for selector in selectors { for selector in selectors {
command = command.args( command = command.args(
make_metadata_args(file, selector, &mut ouput_index) make_metadata_args(file, selector, &mut ouput_index).with_context(|| {
.with_context(|| format!("Failed to mux {:?}", file.path))?, 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"); let temp_path = file.path.with_extension("mp4");
command = command.arg(temp_path.file_name().expect("file name exists")); #[expect(clippy::expect_used, reason = "files must have names")]
{
command = command.arg(temp_path.file_name().expect("file name exists"));
}
if dry_run { if dry_run {
print_command(command); print_command(command);
} else { } else {
let output = command let output = command
.output() .output()
.with_context(|| format!("Failed to mux {:?}", file.path))?; .with_context(|| format!("Failed to mux {}", file.path.display()))?;
if !output.status.success() { if !output.status.success() {
anyhow::bail!( anyhow::bail!(
"ffmpeg failed for {:?}:\n\n{}", "ffmpeg failed for {}:\n\n{}",
file.path, file.path.display(),
String::from_utf8_lossy(&output.stderr) String::from_utf8_lossy(&output.stderr)
); );
} }
@@ -83,23 +109,31 @@ fn mux(dry_run: bool, file: &MediaFile, selectors: &[Selector]) -> Result<()> {
Ok(()) 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>> { 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 stream_type = selector.stream_type.as_ref();
let Some(stream_index) = find_stream_index(file, selector) else { let Some(stream_index) = find_stream_index(file, selector) else {
if selector.optional { if selector.optional {
return Ok(vec![]); return Ok(vec![]);
} else {
anyhow::bail!("unsatisfied stream selection");
} }
anyhow::bail!("unsatisfied stream selection");
}; };
Ok(vec![ Ok(vec![
String::from("-map"), 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( fn make_metadata_args(
file: &MediaFile, file: &MediaFile,
selector: &Selector, selector: &Selector,
@@ -112,9 +146,9 @@ fn make_metadata_args(
let Some(_) = find_stream_index(file, selector) else { let Some(_) = find_stream_index(file, selector) else {
if selector.optional { if selector.optional {
return Ok(vec![]); return Ok(vec![]);
} else {
anyhow::bail!("unsatisfied stream selection");
} }
anyhow::bail!("unsatisfied stream selection");
}; };
let counter = match selector.stream_type { let counter = match selector.stream_type {
@@ -126,8 +160,8 @@ fn make_metadata_args(
*counter = counter.saturating_add(1); *counter = counter.saturating_add(1);
let mut args = vec![ let mut args = vec![
format!("-metadata:s:{}:{}", stream_type, stream_index), format!("-metadata:s:{stream_type}:{stream_index}"),
format!("language={}", stream_language), format!("language={stream_language}"),
]; ];
if selector.stream_type == StreamType::Subtitle { if selector.stream_type == StreamType::Subtitle {
@@ -137,25 +171,26 @@ fn make_metadata_args(
None => match stream_language { None => match stream_language {
"eng" => "English", "eng" => "English",
"jpn" => "Japanese", "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!("-metadata:s:s:{stream_index}"));
args.push(format!("title={}", sub_title)); args.push(format!("title={sub_title}"));
} }
Ok(args) Ok(args)
} }
/// Walk the streams of a file to match the given [Selector].
fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> { fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
let needs_forced = selector.flag == Some(StreamFlag::Forced); let needs_forced = selector.flag == Some(StreamFlag::Forced);
let needs_sdh = selector.flag == Some(StreamFlag::Sdh); let needs_sdh = selector.flag == Some(StreamFlag::Sdh);
match selector.stream_type { match selector.stream_type {
StreamType::Video => match selector.matcher { StreamType::Video => match &selector.matcher {
Matcher::Index(index) => (file.streams.video.len() > index).then_some(index), Matcher::Index(index) => (file.streams.video.len() > *index).then_some(*index),
Matcher::Language(ref language) => file Matcher::Language(language) => file
.streams .streams
.video .video
.iter() .iter()
@@ -163,7 +198,7 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
.filter(|(_, c)| c.language() == Some(language.as_str())) .filter(|(_, c)| c.language() == Some(language.as_str()))
.map(|(i, _)| i) .map(|(i, _)| i)
.next(), .next(),
Matcher::Codec(ref codec) => file Matcher::Codec(codec) => file
.streams .streams
.video .video
.iter() .iter()
@@ -172,9 +207,9 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
.map(|(i, _)| i) .map(|(i, _)| i)
.next(), .next(),
}, },
StreamType::Audio => match selector.matcher { StreamType::Audio => match &selector.matcher {
Matcher::Index(index) => (file.streams.audio.len() > index).then_some(index), Matcher::Index(index) => (file.streams.audio.len() > *index).then_some(*index),
Matcher::Language(ref language) => file Matcher::Language(language) => file
.streams .streams
.audio .audio
.iter() .iter()
@@ -182,7 +217,7 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
.filter(|(_, c)| c.language() == Some(language.as_str())) .filter(|(_, c)| c.language() == Some(language.as_str()))
.map(|(i, _)| i) .map(|(i, _)| i)
.next(), .next(),
Matcher::Codec(ref codec) => file Matcher::Codec(codec) => file
.streams .streams
.audio .audio
.iter() .iter()
@@ -191,9 +226,9 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
.map(|(i, _)| i) .map(|(i, _)| i)
.next(), .next(),
}, },
StreamType::Subtitle => match selector.matcher { StreamType::Subtitle => match &selector.matcher {
Matcher::Index(index) => (file.streams.subtitle.len() > index).then_some(index), Matcher::Index(index) => (file.streams.subtitle.len() > *index).then_some(*index),
Matcher::Language(ref language) => { Matcher::Language(language) => {
file.streams file.streams
.subtitle .subtitle
.iter() .iter()
@@ -214,7 +249,7 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
.map(|(i, _)| i) .map(|(i, _)| i)
.next() .next()
} }
Matcher::Codec(ref codec) => file Matcher::Codec(codec) => file
.streams .streams
.subtitle .subtitle
.iter() .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) { fn print_command(cmd: &Command) {
let program = cmd.get_program().to_string_lossy(); let program = cmd.get_program().to_string_lossy();
@@ -235,15 +272,16 @@ fn print_command(cmd: &Command) {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" "); .join(" ");
println!("{} {}", program, args); println!("{program} {args}");
} }
/// Escape a string to be used by a shell.
fn shell_escape(s: &str) -> String { fn shell_escape(s: &str) -> String {
if s.chars() if s.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c)) .all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c))
{ {
s.to_string() s.to_owned()
} else { } else {
format!("{:?}", s) format!("{s:?}")
} }
} }
+49 -22
View File
@@ -1,20 +1,26 @@
//! Parsing types for `ffmpeg` output.
use core::error::Error; use core::error::Error;
use core::fmt; use core::fmt;
use core::str::FromStr; use core::str::FromStr;
/// The type of a stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamType { pub(crate) enum StreamType {
/// A video stream.
Video, Video,
/// An audio stream.
Audio, Audio,
/// A subtitle stream.
Subtitle, Subtitle,
} }
impl AsRef<str> for StreamType { impl AsRef<str> for StreamType {
fn as_ref(&self) -> &str { fn as_ref(&self) -> &str {
match self { match self {
StreamType::Video => "v", Self::Video => "v",
StreamType::Audio => "a", Self::Audio => "a",
StreamType::Subtitle => "s", Self::Subtitle => "s",
} }
} }
} }
@@ -24,18 +30,22 @@ impl FromStr for StreamType {
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
match s { match s {
"v" => Ok(StreamType::Video), "v" => Ok(Self::Video),
"a" => Ok(StreamType::Audio), "a" => Ok(Self::Audio),
"s" => Ok(StreamType::Subtitle), "s" => Ok(Self::Subtitle),
other => Err(ParseSelectorError::InvalidStreamType(other.to_string())), other => Err(ParseSelectorError::InvalidStreamType(other.to_owned())),
} }
} }
} }
/// A property of a stream to match on.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Matcher { pub(crate) enum Matcher {
/// Match a specific stream index.
Index(usize), Index(usize),
/// Match the first stream of the given language.
Language(String), Language(String),
/// Match the first stream of the given codec.
Codec(String), Codec(String),
} }
@@ -44,19 +54,22 @@ impl FromStr for Matcher {
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Ok(idx) = s.parse::<usize>() { if let Ok(idx) = s.parse::<usize>() {
return Ok(Matcher::Index(idx)); return Ok(Self::Index(idx));
} }
if s.len() == 3 { 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamFlag { pub(crate) enum StreamFlag {
/// Forced flag.
Forced, Forced,
/// SDH flag.
Sdh, Sdh,
} }
@@ -65,24 +78,31 @@ impl FromStr for StreamFlag {
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() { match s.to_ascii_lowercase().as_str() {
"forced" => Ok(StreamFlag::Forced), "forced" => Ok(Self::Forced),
"sdh" => Ok(StreamFlag::Sdh), "sdh" => Ok(Self::Sdh),
other => Err(ParseSelectorError::InvalidFlag(other.to_string())), 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Selector { pub(crate) struct Selector {
/// The type of the stream to select.
pub stream_type: StreamType, pub stream_type: StreamType,
/// The matcher to use when selecting.
pub matcher: Matcher, pub matcher: Matcher,
/// A desired flag.
pub flag: Option<StreamFlag>, pub flag: Option<StreamFlag>,
/// Whether or not to bail if the stream cannot be found.
pub optional: bool, pub optional: bool,
/// The output language of the stream.
pub out_lang: String, pub out_lang: String,
} }
impl Selector { impl Selector {
pub fn language(&self) -> &str { /// Get the target language of the selector.
pub(crate) fn language(&self) -> &str {
&self.out_lang &self.out_lang
} }
} }
@@ -125,7 +145,7 @@ impl FromStr for Selector {
(true, _) => return Err(ParseSelectorError::UnspecifiedLanguage), (true, _) => return Err(ParseSelectorError::UnspecifiedLanguage),
}; };
Ok(Selector { Ok(Self {
stream_type, stream_type,
matcher, matcher,
flag, flag,
@@ -135,15 +155,22 @@ impl FromStr for Selector {
} }
} }
/// Errors that can arise when parsing a selector string.
#[derive(Debug)] #[derive(Debug)]
pub enum ParseSelectorError { pub(crate) enum ParseSelectorError {
/// The selector string is empty.
Empty, Empty,
/// The selector string has an invalid format.
InvalidFormat, InvalidFormat,
/// The requested stream type in invalid.
InvalidStreamType(String), InvalidStreamType(String),
/// The requested flag is invalid.
InvalidFlag(String), InvalidFlag(String),
/// The target language is unspecified.
UnspecifiedLanguage, UnspecifiedLanguage,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl Error for ParseSelectorError {} impl Error for ParseSelectorError {}
impl fmt::Display for ParseSelectorError { impl fmt::Display for ParseSelectorError {
@@ -151,8 +178,8 @@ impl fmt::Display for ParseSelectorError {
match self { match self {
Self::Empty => write!(f, "selector was empty"), Self::Empty => write!(f, "selector was empty"),
Self::InvalidFormat => write!(f, "invalid selector format"), Self::InvalidFormat => write!(f, "invalid selector format"),
Self::InvalidStreamType(s) => write!(f, "invalid stream type '{}'", s), Self::InvalidStreamType(s) => write!(f, "invalid stream type '{s}'"),
Self::InvalidFlag(s) => write!(f, "invalid stream flag '{}'", s), Self::InvalidFlag(s) => write!(f, "invalid stream flag '{s}'"),
Self::UnspecifiedLanguage => write!(f, "unspecified output language"), 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 std::process::Command;
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
@@ -7,11 +9,20 @@ use walkdir::DirEntry;
use crate::model::{FFStream, MediaFile, Streams}; use crate::model::{FFStream, MediaFile, Streams};
/// The top-level containing type for `ffprobe` output.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct FFProbeOutput<S> { struct FFProbeOutput<S> {
/// The list of returned streams.
streams: Vec<S>, 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>> { fn probe_file_streams<S: FFStream>(entry: &DirEntry) -> Result<Vec<S>> {
let output = Command::new("ffprobe") let output = Command::new("ffprobe")
.args([ .args([
@@ -22,16 +33,16 @@ fn probe_file_streams<S: FFStream>(entry: &DirEntry) -> Result<Vec<S>> {
"-show_streams", "-show_streams",
"-select_streams", "-select_streams",
S::FF_TYPE_NAME, 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"), entry.path().to_str().expect("path should be utf8"),
]) ])
.output() .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() { if !output.status.success() {
anyhow::bail!( anyhow::bail!(
"ffprobe failed for {:?}:\n\n{}", "ffprobe failed for {}:\n\n{}",
entry.path(), entry.path().display(),
String::from_utf8_lossy(&output.stderr) String::from_utf8_lossy(&output.stderr)
); );
} }
@@ -40,7 +51,11 @@ fn probe_file_streams<S: FFStream>(entry: &DirEntry) -> Result<Vec<S>> {
Ok(parsed.streams) 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 { Ok(MediaFile {
path: entry.path().to_path_buf(), path: entry.path().to_path_buf(),
byte_size: entry.metadata()?.size(), byte_size: entry.metadata()?.size(),
+12 -15
View File
@@ -1,3 +1,5 @@
//! Helpers for filesystem scanning.
use std::path::Path; use std::path::Path;
use walkdir::WalkDir; use walkdir::WalkDir;
@@ -5,7 +7,8 @@ use walkdir::WalkDir;
use crate::model::MediaFile; use crate::model::MediaFile;
use crate::probe::probe_file; use crate::probe::probe_file;
pub fn scan_directory<T>( /// Recursively scan a directory.
pub(crate) fn scan_directory<T>(
path: &Path, path: &Path,
unknown_length_start: impl FnOnce() -> T, unknown_length_start: impl FnOnce() -> T,
unknown_length_end: impl FnOnce(T), unknown_length_end: impl FnOnce(T),
@@ -18,32 +21,26 @@ pub fn scan_directory<T>(
let files: Vec<_> = WalkDir::new(path) let files: Vec<_> = WalkDir::new(path)
.into_iter() .into_iter()
.filter_map(Result::ok) .filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
.filter(|e| { .filter(|e| {
let is_mkv = e #[expect(clippy::filetype_is_file, reason = "we only want regular files")]
.path() e.file_type().is_file()
.extension() })
.unwrap_or_default() .filter(|e| {
.eq_ignore_ascii_case("mkv"); let ext = e.path().extension().unwrap_or_default();
let is_mp4 = e ext.eq_ignore_ascii_case("mkv") || ext.eq_ignore_ascii_case("mp4")
.path()
.extension()
.unwrap_or_default()
.eq_ignore_ascii_case("mp4");
is_mkv || is_mp4
}) })
.collect(); .collect();
unknown_length_end(spinner); unknown_length_end(spinner);
let mut progress = fixed_length_start(files.len()); 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 let files: Vec<_> = files
.iter() .iter()
.filter_map(|entry| { .filter_map(|entry| {
let file = match probe_file(entry) { let file = match probe_file(entry) {
Ok(file) => Some(file), Ok(file) => Some(file),
Err(err) => { Err(err) => {
print_fn(&progress, &format!("{:?}", err)); print_fn(&progress, &format!("{err:?}"));
None None
} }
}; };
+9 -24
View File
@@ -1,14 +1,16 @@
[package] [package]
name = "flix-cli" name = "flix-cli"
version = "0.0.20" version = "0.1.0"
license-file.workspace = true
description = "CLI for interacting with a flix database" description = "CLI for interacting with a flix database"
repository = "https://github.com/QuantumShade/flix" repository = "https://git.skrundz.dev/quantumshade/flix"
categories = ["command-line-utilities"] keywords = ["flix"]
categories = ["command-line-utilities", "multimedia"]
include = ["/src"]
publish = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs] [package.metadata.docs.rs]
all-features = true all-features = true
@@ -32,7 +34,6 @@ clap = { workspace = true, features = [
"usage", "usage",
] } ] }
flix = { workspace = true, features = ["tmdb"] } flix = { workspace = true, features = ["tmdb"] }
futures = { workspace = true }
sea-orm = { workspace = true, features = ["debug-print", "runtime-tokio"] } sea-orm = { workspace = true, features = ["debug-print", "runtime-tokio"] }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
tokio = { workspace = true, features = ["fs", "macros", "rt"] } tokio = { workspace = true, features = ["fs", "macros", "rt"] }
@@ -40,21 +41,5 @@ toml = { workspace = true, features = ["parse", "serde"] }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
[lints.clippy] [lints]
arithmetic_side_effects = "deny" workspace = true
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"
+15 -4
View File
@@ -1,29 +1,40 @@
//! Command line argument parsing for the `flix` subcommand.
use flix::model::numbers::{EpisodeNumber, SeasonNumber}; use flix::model::numbers::{EpisodeNumber, SeasonNumber};
use chrono::NaiveDate; use chrono::NaiveDate;
use clap::Subcommand; use clap::Subcommand;
/// Subcommand for adding `flix` media.
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum AddCommand { pub(crate) enum AddCommand {
/// Add a flix collection /// Add a flix collection.
Collection { Collection {
/// The collection's title.
#[arg(value_name = "TITLE")] #[arg(value_name = "TITLE")]
title: String, title: String,
/// The collection's overview.
#[arg(value_name = "OVERVIEW")] #[arg(value_name = "OVERVIEW")]
overview: String, overview: String,
}, },
/// Add a flix episode /// Add a flix episode.
Episode { Episode {
/// The episode's show's web slug.
#[arg(value_name = "SHOW_WEB_SLUG")] #[arg(value_name = "SHOW_WEB_SLUG")]
show_slug: String, show_web_slug: String,
/// The episode's season number.
#[arg(value_name = "NUMBER")] #[arg(value_name = "NUMBER")]
season_number: SeasonNumber, season_number: SeasonNumber,
/// The episode's number.
#[arg(value_name = "NUMBER")] #[arg(value_name = "NUMBER")]
episode_number: EpisodeNumber, episode_number: EpisodeNumber,
/// The episode's title.
#[arg(value_name = "TITLE")] #[arg(value_name = "TITLE")]
title: String, title: String,
/// The episode's overview.
#[arg(value_name = "OVERVIEW")] #[arg(value_name = "OVERVIEW")]
overview: String, overview: String,
/// The episode's air date.
#[arg(value_name = "DATE")] #[arg(value_name = "DATE")]
air_date: NaiveDate, air_date: NaiveDate,
}, },
+68 -52
View File
@@ -1,15 +1,18 @@
//! Command line argument parsing.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use clap::{Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
pub mod flix; pub(crate) mod flix;
pub mod tmdb; pub(crate) mod tmdb;
/// Command line argument parser struct.
#[derive(Parser)] #[derive(Parser)]
#[command(version, about, long_about = None)] #[command(version, about, long_about = None)]
pub struct Cli { pub(crate) struct Cli {
/// Use a custom config file /// Use a custom config file.
#[arg( #[arg(
short, short,
long, long,
@@ -17,41 +20,53 @@ pub struct Cli {
default_value = "~/.config/flix/config.toml" default_value = "~/.config/flix/config.toml"
)] )]
config: PathBuf, config: PathBuf,
/// Use a custom cache file.
/// Use a custom cache file
#[arg(short = 'C', long, value_name = "FILE", default_value = "./flix.redb")] #[arg(short = 'C', long, value_name = "FILE", default_value = "./flix.redb")]
cache: PathBuf, cache: PathBuf,
/// Use a custom database file.
/// Use a custom database file
#[arg(short, long, value_name = "FILE", default_value = "./flix.db")] #[arg(short, long, value_name = "FILE", default_value = "./flix.db")]
database: PathBuf, database: PathBuf,
/// Enable tracing.
/// Enable tracing
#[arg(short, long)] #[arg(short, long)]
pub trace: bool, trace: bool,
/// Subcommand to execute.
#[command(subcommand)] #[command(subcommand)]
command: Command, command: Command,
} }
impl Cli { impl Cli {
pub fn config_path(&self) -> PathBuf { /// Get the config file path.
fn expect_home_dir() -> PathBuf { ///
#[allow(clippy::expect_used)] /// # Panics
std::env::home_dir().expect("you do not have a home directory") /// If the user doesn't have a home directory.
} #[inline]
pub(crate) fn config_path(&self) -> PathBuf {
match self.config.strip_prefix("~/") { self.config.strip_prefix("~/").map_or_else(
Ok(path) => expect_home_dir().join(path), |_| self.config.clone(),
Err(_) => self.config.to_owned(), |path| {
} #[expect(
clippy::expect_used,
reason = "without a home directory this program is useless"
)]
std::env::home_dir()
.expect("you do not have a home directory")
.join(path)
},
)
} }
pub fn cache_path(&self) -> &Path { /// Get the path to the cache file.
#[inline]
pub(crate) fn cache_path(&self) -> &Path {
&self.cache &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 self.database
.as_os_str() .as_os_str()
.to_str() .to_str()
@@ -59,77 +74,78 @@ impl Cli {
.ok_or_else(|| anyhow!(".as_os_str().to_str()")) .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 self.command
} }
} }
/// Optional overrides for media information.
#[derive(Args)] #[derive(Args)]
pub struct AddOverrides { pub(crate) struct AddOverrides {
/// Override the displayed title.
#[arg(long)] #[arg(long)]
pub title: Option<String>, pub title: Option<String>,
/// Override the title used to sort.
#[arg(long)] #[arg(long)]
pub sort_title: Option<String>, pub sort_title: Option<String>,
/// Override the filesystem slug.
#[arg(long)] #[arg(long)]
pub fs_slug: Option<String>, pub fs_slug: Option<String>,
/// Overrride the web slug.
#[arg(long)] #[arg(long)]
pub web_slug: Option<String>, pub web_slug: Option<String>,
} }
/// Top level cli commands.
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum Command { pub(crate) enum Command {
/// Initialize a new database /// Initialize a new database.
Init, Init,
/// Add new items to the database /// Add new items to the database.
Add { Add {
/// Overrides.
#[command(flatten)] #[command(flatten)]
overrides: AddOverrides, overrides: AddOverrides,
/// Command.
#[command(subcommand)] #[command(subcommand)]
command: AddCommand, command: AddCommand,
}, },
/// Update an existing item in the database
Update {
#[command(subcommand)]
command: UpdateCommand,
},
} }
/// Wrapper for `add` around different backends.
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum AddCommand { pub(crate) enum AddCommand {
/// Use the flix backend /// Use the flix backend.
Flix { Flix {
/// Command backend.
#[command(subcommand)] #[command(subcommand)]
command: flix::AddCommand, command: flix::AddCommand,
}, },
/// Use the TMDB backend /// Use the TMDB backend.
Tmdb { Tmdb {
/// Command backend.
#[command(subcommand)] #[command(subcommand)]
command: tmdb::Command, command: tmdb::Command,
}, },
} }
impl From<flix::AddCommand> for AddCommand { impl From<flix::AddCommand> for AddCommand {
#[inline]
fn from(value: flix::AddCommand) -> Self { fn from(value: flix::AddCommand) -> Self {
Self::Flix { command: value } Self::Flix { command: value }
} }
} }
impl From<tmdb::Command> for AddCommand { impl From<tmdb::Command> for AddCommand {
fn from(value: tmdb::Command) -> Self { #[inline]
Self::Tmdb { command: value }
}
}
#[derive(Subcommand)]
pub enum UpdateCommand {
/// Use the TMDB backend
Tmdb {
#[command(subcommand)]
command: tmdb::Command,
},
}
impl From<tmdb::Command> for UpdateCommand {
fn from(value: tmdb::Command) -> Self { fn from(value: tmdb::Command) -> Self {
Self::Tmdb { command: value } Self::Tmdb { command: value }
} }
+24 -12
View File
@@ -1,41 +1,53 @@
//! Command line argument parsing for the `tmdb` subcommand.
use flix::model::numbers::{EpisodeNumber, SeasonNumber}; use flix::model::numbers::{EpisodeNumber, SeasonNumber};
use flix::tmdb::model::id::RawId; use flix::tmdb::model::id::TmdbRepr;
use clap::Subcommand; use clap::Subcommand;
/// Subcommand for adding `tmdb` media.
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum Command { pub(crate) enum Command {
/// Process a TMDB collection /// Process a TMDB collection.
Collection { Collection {
/// The collection's ID.
#[arg(value_name = "TMDB_ID")] #[arg(value_name = "TMDB_ID")]
id: RawId, id: TmdbRepr,
}, },
/// Process a TMDB movie /// Process a TMDB movie.
Movie { Movie {
/// The movie's ID.
#[arg(value_name = "TMDB_ID")] #[arg(value_name = "TMDB_ID")]
id: RawId, id: TmdbRepr,
}, },
/// Process a TMDB show /// Process a TMDB show.
Show { Show {
/// The show's ID.
#[arg(value_name = "TMDB_ID")] #[arg(value_name = "TMDB_ID")]
id: RawId, id: TmdbRepr,
}, },
/// Process a TMDB season /// Process a TMDB season.
Season { Season {
/// The season's show's ID.
#[arg(value_name = "TMDB_ID")] #[arg(value_name = "TMDB_ID")]
id: RawId, id: TmdbRepr,
/// The season's number.
#[arg(value_name = "SEASON_NUM")] #[arg(value_name = "SEASON_NUM")]
season: SeasonNumber, season: SeasonNumber,
}, },
/// Process a TMDB episode /// Process a TMDB episode.
#[command(trailing_var_arg = true)] #[command(trailing_var_arg = true)]
Episode { Episode {
/// The episode's show's ID.
#[arg(value_name = "TMDB_ID")] #[arg(value_name = "TMDB_ID")]
id: RawId, id: TmdbRepr,
/// The episode's season's number.
#[arg(value_name = "SEASON_NUM")] #[arg(value_name = "SEASON_NUM")]
season: SeasonNumber, season: SeasonNumber,
/// The episode's number.
#[arg(value_name = "EPISODE_NUM")] #[arg(value_name = "EPISODE_NUM")]
episode: EpisodeNumber, episode: EpisodeNumber,
/// Additional episode numbers for merged media files.
#[arg(value_name = "...")] #[arg(value_name = "...")]
episodes: Vec<EpisodeNumber>, episodes: Vec<EpisodeNumber>,
}, },
+12 -4
View File
@@ -1,21 +1,29 @@
//! CLI configuration.
/// Top level config struct.
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct Config { pub(crate) struct Config {
/// The TMDB config.
tmdb: TmdbConfig, tmdb: TmdbConfig,
} }
/// TMDB config struct.
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct TmdbConfig { pub(crate) struct TmdbConfig {
/// The bearer token to use for API requests.
bearer_token: String, bearer_token: String,
} }
impl Config { impl Config {
pub fn tmdb(&self) -> &TmdbConfig { /// Get the TMDB config.
pub(crate) const fn tmdb(&self) -> &TmdbConfig {
&self.tmdb &self.tmdb
} }
} }
impl TmdbConfig { impl TmdbConfig {
pub fn bearer_token(&self) -> &str { /// Get the bearer token.
pub(crate) fn bearer_token(&self) -> &str {
&self.bearer_token &self.bearer_token
} }
} }
+22 -6
View File
@@ -1,9 +1,17 @@
//! Databse helpers.
use std::path::Path;
use flix::db::connection::Connection; use flix::db::connection::Connection;
use anyhow::{Context, Result, bail}; use anyhow::{Context as _, Result, bail};
use sea_orm::{ConnectOptions, Database}; use sea_orm::{ConnectOptions, Database};
use tokio::fs; 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> { async fn connect(string: String) -> Result<Connection> {
Connection::try_from( Connection::try_from(
Database::connect(ConnectOptions::new(string)) Database::connect(ConnectOptions::new(string))
@@ -14,14 +22,22 @@ async fn connect(string: String) -> Result<Connection> {
.context("Connection::try_from") .context("Connection::try_from")
} }
pub async fn open(database_path: String) -> Result<Connection> { /// Helper for opening an existing database at the given path.
connect(format!("sqlite:{database_path}?mode=rw")).await ///
/// # 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> { /// Helper for creating then opening a database at the given path.
if fs::try_exists(&database_path).await? { ///
/// # 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"); 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 flix::tmdb::{self, CachePolicy, Client, RedbCache};
use anyhow::{Context, Result}; use anyhow::{Context as _, Result};
use clap::Parser; use clap::Parser as _;
use tokio::fs; use tokio::fs;
mod cli; mod cli;
use cli::{AddCommand, Cli, Command, UpdateCommand}; use cli::{AddCommand, Cli, Command};
mod config; mod config;
use config::Config; use config::Config;
@@ -20,47 +24,57 @@ mod db;
mod run; mod run;
#[tokio::main(flavor = "current_thread")] #[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<()> { async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let config = fs::read_to_string(cli.config_path()) let config = fs::read_to_string(cli.config_path())
.await .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()) 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 = cli.database_path()?;
let database_path = Path::new(&database_path);
let config = tmdb::Config::new(config.tmdb().bearer_token().to_owned()); let config = tmdb::Config::new(config.tmdb().bearer_token().to_owned());
let cache = Rc::new(RedbCache::new(cli.cache_path())?); let cache = Arc::new(RedbCache::new(cli.cache_path())?);
let client = Client::new(config, cache, CachePolicy::Full); let client = Client::new(config, cache, CachePolicy::Full);
if cli.trace { if cli.trace() {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG) .with_max_level(tracing::Level::DEBUG)
.with_test_writer() .with_test_writer()
.init(); .init();
} }
match cli.command() { match cli.get_command() {
Command::Init => exec_init(database_path).await?, Command::Init => exec_init(database_path).await?,
Command::Add { command, overrides } => { 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(()) Ok(())
} }
async fn exec_init(database_path: String) -> Result<()> { /// Execute the `init` command.
db::open_new(database_path).await?; ///
/// # Errors
/// Forwards any errors.
async fn exec_init(database_path: &Path) -> Result<()> {
drop(db::open_new(database_path).await?);
Ok(()) Ok(())
} }
/// Execute the `add` command.
///
/// # Errors
/// Forwards any errors.
async fn exec_add( async fn exec_add(
client: Client, client: Client,
database_path: String, database_path: &Path,
command: AddCommand, command: AddCommand,
overrides: AddOverrides, overrides: AddOverrides,
) -> Result<()> { ) -> Result<()> {
@@ -77,15 +91,3 @@ async fn exec_add(
Ok(()) 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::db::entity;
use flix::model::id::{CollectionId, ShowId}; use flix::model::id::{CollectionId, ShowId};
use flix::model::numbers::{EpisodeNumber, SeasonNumber}; use flix::model::numbers::{EpisodeNumber, SeasonNumber};
@@ -5,12 +7,19 @@ use flix::model::text;
use anyhow::Result; use anyhow::Result;
use sea_orm::ActiveValue::{NotSet, Set}; use sea_orm::ActiveValue::{NotSet, Set};
use sea_orm::{ActiveModelTrait, DatabaseConnection, DbErr, TransactionError, TransactionTrait}; use sea_orm::{
ActiveModelTrait as _, DatabaseConnection, DbErr, TransactionError, TransactionTrait as _,
};
use crate::cli::AddOverrides; use crate::cli::AddOverrides;
use crate::cli::flix::AddCommand; 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, db: &DatabaseConnection,
command: AddCommand, command: AddCommand,
overrides: AddOverrides, overrides: AddOverrides,
@@ -50,15 +59,16 @@ pub async fn add(
match result { match result {
Ok(_) => {} Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?, Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
Err(TransactionError::Transaction(err)) => Err(err)?, return Err(err.into());
}; }
println!("Created Collection: {}", title); }
println!("Created Collection: {title}");
Ok(()) Ok(())
} }
AddCommand::Episode { AddCommand::Episode {
show_slug, show_web_slug,
season_number, season_number,
episode_number, episode_number,
title, title,
@@ -70,11 +80,11 @@ pub async fn add(
let title = overrides.title.unwrap_or_else(|| title.clone()); let title = overrides.title.unwrap_or_else(|| title.clone());
Box::pin(async move { 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) .one(txn)
.await? .await?
.ok_or_else(|| { .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 { let flix = entity::info::episodes::ActiveModel {
@@ -95,10 +105,11 @@ pub async fn add(
match result { match result {
Ok(_) => {} Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?, Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
Err(TransactionError::Transaction(err)) => Err(err)?, return Err(err.into());
}; }
println!("Created Episode: {}", title); }
println!("Created Episode: {title}");
Ok(()) Ok(())
} }
+4 -2
View File
@@ -1,2 +1,4 @@
pub mod flix; //! Various runtime backends.
pub mod tmdb;
pub(crate) mod flix;
pub(crate) mod tmdb;
+142 -673
View File
@@ -1,4 +1,6 @@
use std::collections::HashMap; //! The `flix` runtime backend.
use alloc::collections::BTreeMap;
use flix::db::entity; use flix::db::entity;
use flix::model::id::{CollectionId, MovieId, ShowId}; 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, CollectionId as TmdbCollectionId, MovieId as TmdbMovieId, ShowId as TmdbShowId,
}; };
use anyhow::{Context, Result, bail}; use anyhow::{Context as _, Result, bail};
use chrono::{Datelike, Utc}; use chrono::{Datelike as _, Utc};
use sea_orm::ActiveValue::{NotSet, Set}; use sea_orm::ActiveValue::{NotSet, Set};
use sea_orm::{ use sea_orm::{
ActiveModelTrait, DatabaseConnection, DbErr, EntityTrait, TransactionError, TransactionTrait, ActiveModelTrait as _, DatabaseConnection, DbErr, EntityTrait as _, TransactionError,
TransactionTrait as _,
}; };
use crate::cli::AddOverrides; use crate::cli::AddOverrides;
use crate::cli::tmdb::Command; 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, client: Client,
db: &DatabaseConnection, db: &DatabaseConnection,
command: Command, command: Command,
@@ -54,13 +63,15 @@ pub async fn add(
.web_slug .web_slug
.unwrap_or_else(|| text::make_web_slug(&title)); .unwrap_or_else(|| text::make_web_slug(&title));
const COLLECTION_SUFFIX_TO_REMOVE: &str = "-collection"; {
if web_slug.ends_with(COLLECTION_SUFFIX_TO_REMOVE) { const COLLECTION_SUFFIX_TO_REMOVE: &str = "-collection";
web_slug.truncate( if web_slug.ends_with(COLLECTION_SUFFIX_TO_REMOVE) {
web_slug web_slug.truncate(
.len() web_slug
.saturating_sub(COLLECTION_SUFFIX_TO_REMOVE.len()), .len()
); .saturating_sub(COLLECTION_SUFFIX_TO_REMOVE.len()),
);
}
} }
let result: Result<CollectionId, TransactionError<DbErr>> = db let result: Result<CollectionId, TransactionError<DbErr>> = db
@@ -78,7 +89,7 @@ pub async fn add(
.insert(txn) .insert(txn)
.await?; .await?;
entity::tmdb::collections::ActiveModel { _ = entity::tmdb::collections::ActiveModel {
tmdb_id: Set(id), tmdb_id: Set(id),
flix_id: Set(flix.id), flix_id: Set(flix.id),
last_update: Set(Utc::now()), last_update: Set(Utc::now()),
@@ -94,10 +105,11 @@ pub async fn add(
match result { match result {
Ok(_) => {} Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?, Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
Err(TransactionError::Transaction(err)) => Err(err)?, return Err(err.into());
}; }
println!("Created Collection: {}", title); }
println!("Created Collection: {title}");
Ok(()) Ok(())
} }
@@ -145,7 +157,7 @@ pub async fn add(
.insert(txn) .insert(txn)
.await?; .await?;
entity::tmdb::movies::ActiveModel { _ = entity::tmdb::movies::ActiveModel {
tmdb_id: Set(id), tmdb_id: Set(id),
flix_id: Set(flix.id), flix_id: Set(flix.id),
last_update: Set(Utc::now()), last_update: Set(Utc::now()),
@@ -162,10 +174,11 @@ pub async fn add(
match result { match result {
Ok(_) => {} Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?, Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
Err(TransactionError::Transaction(err)) => Err(err)?, return Err(err.into());
}; }
println!("Created Movie: {} ({})", title, year); }
println!("Created Movie: {title} ({year})");
Ok(()) Ok(())
} }
@@ -183,7 +196,7 @@ pub async fn add(
.await .await
.with_context(|| format!("shows().get_details({})", id.into_raw()))?; .with_context(|| format!("shows().get_details({})", id.into_raw()))?;
let mut seasons = Vec::new(); let mut seasons = Vec::new();
let mut episodes = HashMap::new(); let mut episodes = BTreeMap::new();
for season in 1..=show.number_of_seasons { for season in 1..=show.number_of_seasons {
let season = SeasonNumber::new(season); let season = SeasonNumber::new(season);
@@ -191,12 +204,11 @@ pub async fn add(
.seasons() .seasons()
.get_details(id, season, None) .get_details(id, season, None)
.await .await
.with_context(|| { .with_context(|| format!("seasons().get_details({}, {season})", id.into_raw()))
format!("seasons().get_details({}, {})", id.into_raw(), season) {
}) {
Ok(season) => season, Ok(season) => season,
Err(err) => { Err(err) => {
eprintln!("{err:?}"); eprintln!("{err}");
continue; continue;
} }
}; };
@@ -227,11 +239,9 @@ pub async fn add(
Ok(value) => value, Ok(value) => value,
Err(err) => { Err(err) => {
eprintln!( eprintln!(
"skipping episode ({}, {}, {}) - {}", "skipping episode ({}, {}, {episode}) - {err}",
id.into_raw(), id.into_raw(),
season.season_number, season.season_number,
episode,
err
); );
break; break;
} }
@@ -239,7 +249,7 @@ pub async fn add(
season_episodes.push(episode); season_episodes.push(episode);
} }
episodes.insert(season.season_number, season_episodes); drop(episodes.insert(season.season_number, season_episodes));
seasons.push(season); seasons.push(season);
} }
@@ -273,7 +283,7 @@ pub async fn add(
.insert(txn) .insert(txn)
.await?; .await?;
entity::tmdb::shows::ActiveModel { _ = entity::tmdb::shows::ActiveModel {
tmdb_id: Set(id), tmdb_id: Set(id),
flix_id: Set(flix.id), flix_id: Set(flix.id),
last_update: Set(Utc::now()), last_update: Set(Utc::now()),
@@ -283,17 +293,19 @@ pub async fn add(
.await?; .await?;
for season in seasons { for season in seasons {
entity::info::seasons::ActiveModel { drop(
show_id: Set(flix.id), entity::info::seasons::ActiveModel {
season_number: Set(season.season_number), show_id: Set(flix.id),
title: Set(season.title), season_number: Set(season.season_number),
overview: Set(season.overview), title: Set(season.title),
date: Set(season.air_date), overview: Set(season.overview),
} date: Set(season.air_date),
.insert(txn) }
.await?; .insert(txn)
.await?,
);
entity::tmdb::seasons::ActiveModel { _ = entity::tmdb::seasons::ActiveModel {
tmdb_show: Set(id), tmdb_show: Set(id),
tmdb_season: Set(season.season_number), tmdb_season: Set(season.season_number),
flix_show: Set(flix.id), flix_show: Set(flix.id),
@@ -306,18 +318,20 @@ pub async fn add(
for (season, episodes) in episodes { for (season, episodes) in episodes {
for episode in episodes { for episode in episodes {
entity::info::episodes::ActiveModel { drop(
show_id: Set(flix.id), entity::info::episodes::ActiveModel {
season_number: Set(season), show_id: Set(flix.id),
episode_number: Set(episode.episode_number), season_number: Set(season),
title: Set(episode.title), episode_number: Set(episode.episode_number),
overview: Set(episode.overview), title: Set(episode.title),
date: Set(episode.air_date), overview: Set(episode.overview),
} date: Set(episode.air_date),
.insert(txn) }
.await?; .insert(txn)
.await?,
);
entity::tmdb::episodes::ActiveModel { _ = entity::tmdb::episodes::ActiveModel {
tmdb_show: Set(id), tmdb_show: Set(id),
tmdb_season: Set(season), tmdb_season: Set(season),
tmdb_episode: Set(episode.episode_number), tmdb_episode: Set(episode.episode_number),
@@ -339,10 +353,11 @@ pub async fn add(
match result { match result {
Ok(_) => {} Ok(_) => {}
Err(TransactionError::Connection(err)) => Err(err)?, Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
Err(TransactionError::Transaction(err)) => Err(err)?, return Err(err.into());
}; }
println!("Created Show: {} ({})", title, year); }
println!("Created Show: {title} ({year})");
Ok(()) Ok(())
} }
@@ -366,11 +381,7 @@ pub async fn add(
.get_details(id, season_number, None) .get_details(id, season_number, None)
.await .await
.with_context(|| { .with_context(|| {
format!( format!("seasons().get_details({}, {season_number})", id.into_raw())
"seasons().get_details({}, {})",
id.into_raw(),
season_number
)
})?; })?;
let mut episodes = Vec::new(); let mut episodes = Vec::new();
@@ -391,11 +402,9 @@ pub async fn add(
Ok(value) => value, Ok(value) => value,
Err(err) => { Err(err) => {
eprintln!( eprintln!(
"skipping episode ({}, {}, {}) - {}", "skipping episode ({}, {}, {episode}) - {err}",
id.into_raw(), id.into_raw(),
season.season_number, season.season_number,
episode,
err
); );
break; break;
} }
@@ -406,17 +415,19 @@ pub async fn add(
let result: Result<(), TransactionError<DbErr>> = db let result: Result<(), TransactionError<DbErr>> = db
.transaction(|txn| { .transaction(|txn| {
Box::pin(async move { Box::pin(async move {
entity::info::seasons::ActiveModel { drop(
show_id: Set(show.flix_id), entity::info::seasons::ActiveModel {
season_number: Set(season_number), show_id: Set(show.flix_id),
title: Set(season.title), season_number: Set(season_number),
overview: Set(season.overview), title: Set(season.title),
date: Set(season.air_date), overview: Set(season.overview),
} date: Set(season.air_date),
.insert(txn) }
.await?; .insert(txn)
.await?,
);
entity::tmdb::seasons::ActiveModel { _ = entity::tmdb::seasons::ActiveModel {
tmdb_show: Set(show.tmdb_id), tmdb_show: Set(show.tmdb_id),
tmdb_season: Set(season_number), tmdb_season: Set(season_number),
flix_show: Set(show.flix_id), flix_show: Set(show.flix_id),
@@ -427,18 +438,20 @@ pub async fn add(
.await?; .await?;
for episode in episodes { for episode in episodes {
entity::info::episodes::ActiveModel { drop(
show_id: Set(show.flix_id), entity::info::episodes::ActiveModel {
season_number: Set(season_number), show_id: Set(show.flix_id),
episode_number: Set(episode.episode_number), season_number: Set(season_number),
title: Set(episode.title), episode_number: Set(episode.episode_number),
overview: Set(episode.overview), title: Set(episode.title),
date: Set(episode.air_date), overview: Set(episode.overview),
} date: Set(episode.air_date),
.insert(txn) }
.await?; .insert(txn)
.await?,
);
entity::tmdb::episodes::ActiveModel { _ = entity::tmdb::episodes::ActiveModel {
tmdb_show: Set(show.tmdb_id), tmdb_show: Set(show.tmdb_id),
tmdb_season: Set(season_number), tmdb_season: Set(season_number),
tmdb_episode: Set(episode.episode_number), tmdb_episode: Set(episode.episode_number),
@@ -458,14 +471,14 @@ pub async fn add(
.await; .await;
match result { match result {
Ok(_) => {} Ok(()) => {}
Err(TransactionError::Connection(err)) => Err(err)?, Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
Err(TransactionError::Transaction(err)) => Err(err)?, return Err(err.into());
}; }
}
println!( println!(
"Created Season: {} S{}", "Created Season: {} S{season_number}",
show.flix_id.into_raw(), show.flix_id.into_raw(),
season_number
); );
Ok(()) Ok(())
@@ -476,19 +489,10 @@ pub async fn add(
episode, episode,
episodes, episodes,
} => { } => {
let id = TmdbShowId::from_raw(id); /// Fetch and store episode information.
let season_number = season; ///
/// # Errors
let Some(show) = entity::tmdb::shows::Entity::find_by_id(id).one(db).await? else { /// Forwards any database transaction failures or TMDB API failures.
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( async fn fetch_episode(
client: &Client, client: &Client,
db: &DatabaseConnection, db: &DatabaseConnection,
@@ -512,24 +516,26 @@ pub async fn add(
.get_details(id, season, episode_number, None) .get_details(id, season, episode_number, None)
.await .await
.with_context(|| { .with_context(|| {
format!("episodes().get_details({}, {})", id.into_raw(), season) format!("episodes().get_details({}, {season})", id.into_raw())
})?; })?;
let result: Result<(), TransactionError<DbErr>> = db let result: Result<(), TransactionError<DbErr>> = db
.transaction(|txn| { .transaction(|txn| {
Box::pin(async move { Box::pin(async move {
entity::info::episodes::ActiveModel { drop(
show_id: Set(flix_id), entity::info::episodes::ActiveModel {
season_number: Set(season), show_id: Set(flix_id),
episode_number: Set(episode_number), season_number: Set(season),
title: Set(episode.title), episode_number: Set(episode_number),
overview: Set(episode.overview), title: Set(episode.title),
date: Set(episode.air_date), overview: Set(episode.overview),
} date: Set(episode.air_date),
.insert(txn) }
.await?; .insert(txn)
.await?,
);
entity::tmdb::episodes::ActiveModel { _ = entity::tmdb::episodes::ActiveModel {
tmdb_show: Set(tmdb_id), tmdb_show: Set(tmdb_id),
tmdb_season: Set(season), tmdb_season: Set(season),
tmdb_episode: Set(episode_number), tmdb_episode: Set(episode_number),
@@ -548,20 +554,32 @@ pub async fn add(
.await; .await;
match result { match result {
Ok(_) => {} Ok(()) => {}
Err(TransactionError::Connection(err)) => Err(err)?, Err(TransactionError::Connection(err) | TransactionError::Transaction(err)) => {
Err(TransactionError::Transaction(err)) => Err(err)?, return Err(err.into());
}; }
}
println!( println!(
"Created Episode: {} S{}E{}", "Created Episode: {} S{season}E{episode_number}",
flix_id.into_raw(), flix_id.into_raw(),
season,
episode_number
); );
Ok(()) 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 flix_id = show.flix_id;
let tmdb_id = show.tmdb_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] [package]
name = "flix-db" name = "flix-db"
version = "0.0.20" version = "0.1.0"
license-file.workspace = true
description = "Types for storing persistent data about media" description = "Types for storing persistent data about media"
repository = "https://github.com/QuantumShade/flix" repository = "https://git.skrundz.dev/quantumshade/flix"
categories = [] keywords = ["flix"]
categories = ["multimedia"]
include = ["/src"]
publish = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs] [package.metadata.docs.rs]
all-features = true all-features = true
@@ -29,7 +31,7 @@ flix-tmdb = { workspace = true, features = ["sea-orm"], optional = true }
[dev-dependencies] [dev-dependencies]
sea-orm-migration = { workspace = true, features = ["runtime-tokio-rustls"] } sea-orm-migration = { workspace = true, features = ["runtime-tokio-rustls"] }
tokio = { version = "^1", default-features = false, features = [ tokio = { workspace = true, features = [
"macros", "macros",
"rt", "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::{DatabaseConnection, DbErr};
use sea_orm_migration::MigratorTrait as _; use sea_orm_migration::MigratorTrait as _;
/// A newtype wrapping a [DatabaseConnection] /// A newtype wrapping a [`DatabaseConnection`].
#[derive(Debug)]
pub struct Connection(DatabaseConnection); pub struct Connection(DatabaseConnection);
impl Connection { impl Connection {
/// Helper function for applying database migrations while wrapping a /// 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> { 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?; 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?;
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 { impl AsRef<DatabaseConnection> for Connection {
#[inline]
fn as_ref(&self) -> &DatabaseConnection { fn as_ref(&self) -> &DatabaseConnection {
&self.0 &self.0
} }
+183 -145
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 { pub mod libraries {
use flix_model::id::LibraryId; use flix_model::id::LibraryId;
@@ -10,42 +10,43 @@ pub mod libraries {
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
/// The database representation of a library media folder /// The database representation of a library media folder.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_libraries")] #[sea_orm(table_name = "flix_libraries")]
pub struct Model { pub struct Model {
/// The library's ID /// The library's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: LibraryId, pub id: LibraryId,
/// The library's directory /// The library's directory.
pub directory: PathBytes, pub directory: PathBytes,
/// The library's last scan data /// The library's last scan data.
pub last_scan_date: Option<DateTime<Utc>>, 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>, pub last_scan_duration: Option<Seconds>,
/// Collections that are part of this library /// Collections that are part of this library.
#[sea_orm(has_many)] #[sea_orm(has_many)]
pub collections: HasMany<super::collections::Entity>, pub collections: HasMany<super::collections::Entity>,
/// Movies that are part of this library /// Movies that are part of this library.
#[sea_orm(has_many)] #[sea_orm(has_many)]
pub movies: HasMany<super::movies::Entity>, pub movies: HasMany<super::movies::Entity>,
/// Shows that are part of this library /// Shows that are part of this library.
#[sea_orm(has_many)] #[sea_orm(has_many)]
pub shows: HasMany<super::shows::Entity>, pub shows: HasMany<super::shows::Entity>,
/// Seasons that are part of this library /// Seasons that are part of this library.
#[sea_orm(has_many)] #[sea_orm(has_many)]
pub seasons: HasMany<super::seasons::Entity>, pub seasons: HasMany<super::seasons::Entity>,
/// Episodes that are part of this library /// Episodes that are part of this library.
#[sea_orm(has_many)] #[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>, pub episodes: HasMany<super::episodes::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Collection entity /// Collection entity.
pub mod collections { pub mod collections {
use flix_model::id::{CollectionId, LibraryId}; use flix_model::id::{CollectionId, LibraryId};
@@ -55,25 +56,25 @@ pub mod collections {
use crate::entity; use crate::entity;
/// The database representation of a collection media folder /// The database representation of a collection media folder.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_collections")] #[sea_orm(table_name = "flix_collections")]
pub struct Model { pub struct Model {
/// The collection's ID /// The collection's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: CollectionId, pub id: CollectionId,
/// The collection's parent /// The collection's parent.
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub parent_id: Option<CollectionId>, pub parent_id: Option<CollectionId>,
/// The collection's library ID /// The collection's library ID.
pub library_id: LibraryId, pub library_id: LibraryId,
/// The collection's directory /// The collection's directory.
pub directory: PathBytes, pub directory: PathBytes,
/// The collection's poster path /// The collection's poster path.
pub relative_poster_path: Option<String>, pub relative_poster_path: Option<String>,
/// This collection's parent /// This collection's parent.
#[sea_orm( #[sea_orm(
self_ref, self_ref,
relation_enum = "Parent", relation_enum = "Parent",
@@ -83,7 +84,7 @@ pub mod collections {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub parent: HasOne<Entity>, pub parent: HasOne<Entity>,
/// The library this collection belongs to /// The library this collection belongs to.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "library_id", from = "library_id",
@@ -92,7 +93,7 @@ pub mod collections {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub library: HasOne<super::libraries::Entity>, pub library: HasOne<super::libraries::Entity>,
/// The info for this collection /// The info for this collection.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
relation_enum = "Info", relation_enum = "Info",
@@ -103,15 +104,16 @@ pub mod collections {
)] )]
pub info: HasOne<entity::info::collections::Entity>, pub info: HasOne<entity::info::collections::Entity>,
/// The watched info for this collection /// The watched info for this collection.
#[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")] #[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")]
pub watched: HasMany<entity::watched::collections::Entity>, pub watched: HasMany<entity::watched::collections::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Movie entity /// Movie entity.
pub mod movies { pub mod movies {
use flix_model::id::{CollectionId, LibraryId, MovieId}; use flix_model::id::{CollectionId, LibraryId, MovieId};
@@ -121,27 +123,27 @@ pub mod movies {
use crate::entity; use crate::entity;
/// The database representation of a movie media folder /// The database representation of a movie media folder.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_movies")] #[sea_orm(table_name = "flix_movies")]
pub struct Model { pub struct Model {
/// The movie's ID /// The movie's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: MovieId, pub id: MovieId,
/// The movie's parent /// The movie's parent.
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub parent_id: Option<CollectionId>, pub parent_id: Option<CollectionId>,
/// The movie's library /// The movie's library.
pub library_id: LibraryId, pub library_id: LibraryId,
/// The movie's directory /// The movie's directory.
pub directory: PathBytes, pub directory: PathBytes,
/// The movie's media path /// The movie's media path.
pub relative_media_path: String, pub relative_media_path: String,
/// The movie's poster path /// The movie's poster path.
pub relative_poster_path: Option<String>, pub relative_poster_path: Option<String>,
/// This movie's parent /// This movie's parent.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "parent_id", from = "parent_id",
@@ -150,7 +152,7 @@ pub mod movies {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub parent: HasOne<super::collections::Entity>, pub parent: HasOne<super::collections::Entity>,
/// The library this movie belongs to /// The library this movie belongs to.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "library_id", from = "library_id",
@@ -159,7 +161,7 @@ pub mod movies {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub library: HasOne<super::libraries::Entity>, pub library: HasOne<super::libraries::Entity>,
/// The info for this movie /// The info for this movie.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
relation_enum = "Info", relation_enum = "Info",
@@ -170,15 +172,16 @@ pub mod movies {
)] )]
pub info: HasOne<entity::info::movies::Entity>, pub info: HasOne<entity::info::movies::Entity>,
/// The watched info for this movie /// The watched info for this movie.
#[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")] #[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")]
pub watched: HasMany<entity::watched::movies::Entity>, pub watched: HasMany<entity::watched::movies::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Show entity /// Show entity.
pub mod shows { pub mod shows {
use flix_model::id::{CollectionId, LibraryId, ShowId}; use flix_model::id::{CollectionId, LibraryId, ShowId};
@@ -188,25 +191,25 @@ pub mod shows {
use crate::entity; use crate::entity;
/// The database representation of a show media folder /// The database representation of a show media folder.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_shows")] #[sea_orm(table_name = "flix_shows")]
pub struct Model { pub struct Model {
/// The show's ID /// The show's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: ShowId, pub id: ShowId,
/// The show's parent /// The show's parent.
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub parent_id: Option<CollectionId>, pub parent_id: Option<CollectionId>,
/// The show's library /// The show's library.
pub library_id: LibraryId, pub library_id: LibraryId,
/// The show's directory /// The show's directory.
pub directory: PathBytes, pub directory: PathBytes,
/// The show's poster path /// The show's poster path.
pub relative_poster_path: Option<String>, pub relative_poster_path: Option<String>,
/// This show's parent /// This show's parent.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "parent_id", from = "parent_id",
@@ -215,7 +218,7 @@ pub mod shows {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub parent: HasOne<super::collections::Entity>, pub parent: HasOne<super::collections::Entity>,
/// The library this show belongs to /// The library this show belongs to.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "library_id", from = "library_id",
@@ -224,7 +227,7 @@ pub mod shows {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub library: HasOne<super::libraries::Entity>, pub library: HasOne<super::libraries::Entity>,
/// The info for this show /// The info for this show.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
relation_enum = "Info", relation_enum = "Info",
@@ -235,21 +238,22 @@ pub mod shows {
)] )]
pub info: HasOne<entity::info::shows::Entity>, 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)] #[sea_orm(has_many)]
pub seasons: HasMany<super::seasons::Entity>, pub seasons: HasMany<super::seasons::Entity>,
/// Episodes that are part of this show /// Episodes that are part of this show.
#[sea_orm(has_many)] #[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>, pub episodes: HasMany<super::episodes::Entity>,
/// The watched info for this show /// The watched info for this show.
#[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")] #[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")]
pub watched: HasMany<entity::watched::shows::Entity>, pub watched: HasMany<entity::watched::shows::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Season entity /// Season entity.
pub mod seasons { pub mod seasons {
use flix_model::id::{LibraryId, ShowId}; use flix_model::id::{LibraryId, ShowId};
use flix_model::numbers::SeasonNumber; use flix_model::numbers::SeasonNumber;
@@ -260,25 +264,25 @@ pub mod seasons {
use crate::entity; use crate::entity;
/// The database representation of a season media folder /// The database representation of a season media folder.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_seasons")] #[sea_orm(table_name = "flix_seasons")]
pub struct Model { pub struct Model {
/// The season's show's ID /// The season's show's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub show_id: ShowId, pub show_id: ShowId,
/// The season's number /// The season's number.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub season_number: SeasonNumber, pub season_number: SeasonNumber,
/// The season's library /// The season's library.
pub library_id: LibraryId, pub library_id: LibraryId,
/// The season's directory /// The season's directory.
pub directory: PathBytes, pub directory: PathBytes,
/// The season's poster path /// The season's poster path.
pub relative_poster_path: Option<String>, pub relative_poster_path: Option<String>,
/// This season's show /// This season's show.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "show_id", from = "show_id",
@@ -287,7 +291,7 @@ pub mod seasons {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub show: HasOne<super::shows::Entity>, pub show: HasOne<super::shows::Entity>,
/// The library this season belongs to /// The library this season belongs to.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "library_id", from = "library_id",
@@ -296,7 +300,7 @@ pub mod seasons {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub library: HasOne<super::libraries::Entity>, pub library: HasOne<super::libraries::Entity>,
/// The info for this season /// The info for this season.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
relation_enum = "Info", relation_enum = "Info",
@@ -307,10 +311,10 @@ pub mod seasons {
)] )]
pub info: HasOne<entity::info::seasons::Entity>, 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)] #[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>, pub episodes: HasMany<super::episodes::Entity>,
/// The watched info for this season /// The watched info for this season.
#[sea_orm( #[sea_orm(
has_many, has_many,
relation_enum = "Watched", relation_enum = "Watched",
@@ -320,10 +324,11 @@ pub mod seasons {
pub watched: HasMany<entity::watched::seasons::Entity>, pub watched: HasMany<entity::watched::seasons::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Episode entity /// Episode entity.
pub mod episodes { pub mod episodes {
use flix_model::id::{LibraryId, ShowId}; use flix_model::id::{LibraryId, ShowId};
use flix_model::numbers::{EpisodeNumber, SeasonNumber}; use flix_model::numbers::{EpisodeNumber, SeasonNumber};
@@ -334,32 +339,32 @@ pub mod episodes {
use crate::entity; use crate::entity;
/// The database representation of a episode media folder /// The database representation of a episode media folder.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_episodes")] #[sea_orm(table_name = "flix_episodes")]
pub struct Model { pub struct Model {
/// The episode's show's ID /// The episode's show's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub show_id: ShowId, pub show_id: ShowId,
/// The episode's season's number /// The episode's season's number.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub season_number: SeasonNumber, pub season_number: SeasonNumber,
/// The episode's number /// The episode's number.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub episode_number: EpisodeNumber, pub episode_number: EpisodeNumber,
/// The number of additional contained episodes /// The number of additional contained episodes.
pub count: u8, pub count: u8,
/// The episode's library /// The episode's library.
pub library_id: LibraryId, pub library_id: LibraryId,
/// The episode's directory /// The episode's directory.
pub directory: PathBytes, pub directory: PathBytes,
/// The episode's media path /// The episode's media path.
pub relative_media_path: String, pub relative_media_path: String,
/// The episode's poster path /// The episode's poster path.
pub relative_poster_path: Option<String>, pub relative_poster_path: Option<String>,
/// This episode's show /// This episode's show.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "show_id", from = "show_id",
@@ -368,7 +373,7 @@ pub mod episodes {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub show: HasOne<super::shows::Entity>, pub show: HasOne<super::shows::Entity>,
/// This episode's season /// This episode's season.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "(show_id, season_number)", from = "(show_id, season_number)",
@@ -377,7 +382,7 @@ pub mod episodes {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub season: HasOne<super::seasons::Entity>, pub season: HasOne<super::seasons::Entity>,
/// The library this episode belongs to /// The library this episode belongs to.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "library_id", from = "library_id",
@@ -386,7 +391,7 @@ pub mod episodes {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub library: HasOne<super::libraries::Entity>, pub library: HasOne<super::libraries::Entity>,
/// The info for this episode /// The info for this episode.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
relation_enum = "Info", relation_enum = "Info",
@@ -397,7 +402,7 @@ pub mod episodes {
)] )]
pub info: HasOne<entity::info::episodes::Entity>, pub info: HasOne<entity::info::episodes::Entity>,
/// The watched info for this episode /// The watched info for this episode.
#[sea_orm( #[sea_orm(
has_many, has_many,
relation_enum = "Watched", relation_enum = "Watched",
@@ -407,23 +412,26 @@ pub mod episodes {
pub watched: HasMany<entity::watched::episodes::Entity>, pub watched: HasMany<entity::watched::episodes::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Macros for creating content entities /// Macros for creating content entities.
#[cfg(test)] #[cfg(test)]
pub mod test { pub mod test {
macro_rules! make_content_library { macro_rules! make_content_library {
($db:expr, $id:expr) => { ($db:expr, $id:expr) => {
$crate::entity::content::libraries::ActiveModel { drop(
id: Set(::flix_model::id::LibraryId::from_raw($id)), $crate::entity::content::libraries::ActiveModel {
directory: Set(::std::path::PathBuf::new().into()), id: Set(::flix_model::id::LibraryId::from_raw($id)),
last_scan_date: Set(None), directory: Set(::std::path::PathBuf::new().into()),
last_scan_duration: Set(None), last_scan_date: Set(None),
} last_scan_duration: Set(None),
.insert($db) }
.await .insert($db)
.expect("insert"); .await
.expect("insert"),
);
}; };
} }
pub(crate) use make_content_library; pub(crate) use make_content_library;
@@ -431,16 +439,18 @@ pub mod test {
macro_rules! make_content_collection { macro_rules! make_content_collection {
($db:expr, $lid:expr, $id:expr, $pid:expr) => { ($db:expr, $lid:expr, $id:expr, $pid:expr) => {
$crate::entity::info::test::make_info_collection!($db, $id); $crate::entity::info::test::make_info_collection!($db, $id);
$crate::entity::content::collections::ActiveModel { drop(
id: Set(::flix_model::id::CollectionId::from_raw($id)), $crate::entity::content::collections::ActiveModel {
parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)), id: Set(::flix_model::id::CollectionId::from_raw($id)),
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)), parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
directory: Set(::std::path::PathBuf::new().into()), library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
relative_poster_path: Set(::core::option::Option::None), directory: Set(::std::path::PathBuf::new().into()),
} relative_poster_path: Set(::core::option::Option::None),
.insert($db) }
.await .insert($db)
.expect("insert"); .await
.expect("insert"),
);
}; };
} }
pub(crate) use make_content_collection; pub(crate) use make_content_collection;
@@ -448,17 +458,19 @@ pub mod test {
macro_rules! make_content_movie { macro_rules! make_content_movie {
($db:expr, $lid:expr, $id:expr, $pid:expr) => { ($db:expr, $lid:expr, $id:expr, $pid:expr) => {
$crate::entity::info::test::make_info_movie!($db, $id); $crate::entity::info::test::make_info_movie!($db, $id);
$crate::entity::content::movies::ActiveModel { drop(
id: Set(::flix_model::id::MovieId::from_raw($id)), $crate::entity::content::movies::ActiveModel {
parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)), id: Set(::flix_model::id::MovieId::from_raw($id)),
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)), parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
directory: Set(::std::path::PathBuf::new().into()), library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
relative_media_path: Set(::std::string::String::new()), directory: Set(::std::path::PathBuf::new().into()),
relative_poster_path: Set(::core::option::Option::None), relative_media_path: Set(::alloc::string::String::new()),
} relative_poster_path: Set(::core::option::Option::None),
.insert($db) }
.await .insert($db)
.expect("insert"); .await
.expect("insert"),
);
}; };
} }
pub(crate) use make_content_movie; pub(crate) use make_content_movie;
@@ -466,16 +478,18 @@ pub mod test {
macro_rules! make_content_show { macro_rules! make_content_show {
($db:expr, $lid:expr, $id:expr, $pid:expr) => { ($db:expr, $lid:expr, $id:expr, $pid:expr) => {
$crate::entity::info::test::make_info_show!($db, $id); $crate::entity::info::test::make_info_show!($db, $id);
$crate::entity::content::shows::ActiveModel { drop(
id: Set(::flix_model::id::ShowId::from_raw($id)), $crate::entity::content::shows::ActiveModel {
parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)), id: Set(::flix_model::id::ShowId::from_raw($id)),
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)), parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
directory: Set(::std::path::PathBuf::new().into()), library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
relative_poster_path: Set(::core::option::Option::None), directory: Set(::std::path::PathBuf::new().into()),
} relative_poster_path: Set(::core::option::Option::None),
.insert($db) }
.await .insert($db)
.expect("insert"); .await
.expect("insert"),
);
}; };
} }
pub(crate) use make_content_show; pub(crate) use make_content_show;
@@ -483,16 +497,18 @@ pub mod test {
macro_rules! make_content_season { macro_rules! make_content_season {
($db:expr, $lid:expr, $show:expr, $season:expr) => { ($db:expr, $lid:expr, $show:expr, $season:expr) => {
$crate::entity::info::test::make_info_season!($db, $show, $season); $crate::entity::info::test::make_info_season!($db, $show, $season);
$crate::entity::content::seasons::ActiveModel { drop(
show_id: Set(::flix_model::id::ShowId::from_raw($show)), $crate::entity::content::seasons::ActiveModel {
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)), show_id: Set(::flix_model::id::ShowId::from_raw($show)),
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)), season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
directory: Set(::std::path::PathBuf::new().into()), library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
relative_poster_path: Set(::core::option::Option::None), directory: Set(::std::path::PathBuf::new().into()),
} relative_poster_path: Set(::core::option::Option::None),
.insert($db) }
.await .insert($db)
.expect("insert"); .await
.expect("insert"),
);
}; };
} }
pub(crate) use make_content_season; pub(crate) use make_content_season;
@@ -506,19 +522,21 @@ pub mod test {
}; };
(@make, $db:expr, $lid:expr, $show:expr, $season:expr, $episode:expr, $count:literal) => { (@make, $db:expr, $lid:expr, $show:expr, $season:expr, $episode:expr, $count:literal) => {
$crate::entity::info::test::make_info_episode!($db, $show, $season, $episode); $crate::entity::info::test::make_info_episode!($db, $show, $season, $episode);
$crate::entity::content::episodes::ActiveModel { drop(
show_id: Set(::flix_model::id::ShowId::from_raw($show)), $crate::entity::content::episodes::ActiveModel {
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)), show_id: Set(::flix_model::id::ShowId::from_raw($show)),
episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)), season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
count: Set($count), episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
library_id: Set(::flix_model::id::LibraryId::from_raw($lid)), count: Set($count),
directory: Set(::std::path::PathBuf::new().into()), library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
relative_media_path: Set(::std::string::String::new()), directory: Set(::std::path::PathBuf::new().into()),
relative_poster_path: Set(::core::option::Option::None), relative_media_path: Set(::alloc::string::String::new()),
} relative_poster_path: Set(::core::option::Option::None),
.insert($db) }
.await .insert($db)
.expect("insert"); .await
.expect("insert"),
);
}; };
} }
pub(crate) use make_content_episode; pub(crate) use make_content_episode;
@@ -550,7 +568,10 @@ mod tests {
use super::super::tests::get_error_kind; use super::super::tests::get_error_kind;
use super::super::tests::{noneable, notsettable}; use super::super::tests::{noneable, notsettable};
#[cfg(not(miri))]
#[tokio::test] #[tokio::test]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn use_test_macros() { async fn use_test_macros() {
let db = new_initialized_memory_db().await; let db = new_initialized_memory_db().await;
@@ -562,8 +583,10 @@ mod tests {
make_content_episode!(&db, 1, 1, 1, 1); make_content_episode!(&db, 1, 1, 1, 1);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_library { macro_rules! assert_library {
@@ -601,8 +624,11 @@ mod tests {
assert_library!(&db, 6, Success; last_scan_duration); assert_library!(&db, 6, Success; last_scan_duration);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_collection { macro_rules! assert_collection {
@@ -656,8 +682,11 @@ mod tests {
assert_collection!(&db, 7, None, 1, Success; relative_poster_path); assert_collection!(&db, 7, None, 1, Success; relative_poster_path);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_movie { macro_rules! assert_movie {
@@ -718,8 +747,11 @@ mod tests {
assert_movie!(&db, 8, None, 1, Success; relative_poster_path); assert_movie!(&db, 8, None, 1, Success; relative_poster_path);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_show { macro_rules! assert_show {
@@ -776,8 +808,11 @@ mod tests {
assert_show!(&db, 7, None, 1, Success; relative_poster_path); assert_show!(&db, 7, None, 1, Success; relative_poster_path);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_season { macro_rules! assert_season {
@@ -828,8 +863,11 @@ mod tests {
assert_season!(&db, 1, 7, 1, Success; relative_poster_path); assert_season!(&db, 1, 7, 1, Success; relative_poster_path);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_episode { macro_rules! assert_episode {
+149 -119
View File
@@ -1,7 +1,7 @@
//! This module contains entities for storing media information such as //! This module contains entities for storing media information such as
//! titles and overviews //! titles and overviews.
/// Collection entity /// Collection entity.
pub mod collections { pub mod collections {
use flix_model::id::CollectionId; use flix_model::id::CollectionId;
@@ -9,38 +9,39 @@ pub mod collections {
use crate::entity; use crate::entity;
/// The database representation of a flix collection /// The database representation of a flix collection.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_info_collections")] #[sea_orm(table_name = "flix_info_collections")]
pub struct Model { pub struct Model {
/// The collection's ID /// The collection's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: CollectionId, pub id: CollectionId,
/// The collection's title /// The collection's title.
pub title: String, pub title: String,
/// The collection's overview /// The collection's overview.
pub overview: String, pub overview: String,
/// The sortable title /// The sortable title.
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub sort_title: String, pub sort_title: String,
/// The filesystem-safe slug /// The filesystem-safe slug.
#[sea_orm(indexed, unique)] #[sea_orm(indexed, unique)]
pub fs_slug: String, pub fs_slug: String,
/// The url-safe slug /// The url-safe slug.
#[sea_orm(indexed, unique)] #[sea_orm(indexed, unique)]
pub web_slug: String, pub web_slug: String,
/// Potential content for this collection /// Potential content for this collection.
#[sea_orm(has_one)] #[sea_orm(has_one)]
pub content: HasOne<entity::content::collections::Entity>, pub content: HasOne<entity::content::collections::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Movie entity /// Movie entity.
pub mod movies { pub mod movies {
use flix_model::id::MovieId; use flix_model::id::MovieId;
@@ -49,43 +50,44 @@ pub mod movies {
use crate::entity; use crate::entity;
/// The database representation of a flix movie /// The database representation of a flix movie.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_info_movies")] #[sea_orm(table_name = "flix_info_movies")]
pub struct Model { pub struct Model {
/// The movie's ID /// The movie's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: MovieId, pub id: MovieId,
/// The movie's title /// The movie's title.
pub title: String, pub title: String,
/// The movie's tagline /// The movie's tagline.
pub tagline: String, pub tagline: String,
/// The movie's overview /// The movie's overview.
pub overview: String, pub overview: String,
/// The movie's release date /// The movie's release date.
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub date: NaiveDate, pub date: NaiveDate,
/// The sortable title /// The sortable title.
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub sort_title: String, pub sort_title: String,
/// The filesystem-safe slug /// The filesystem-safe slug.
#[sea_orm(indexed, unique)] #[sea_orm(indexed, unique)]
pub fs_slug: String, pub fs_slug: String,
/// The url-safe slug /// The url-safe slug.
#[sea_orm(indexed, unique)] #[sea_orm(indexed, unique)]
pub web_slug: String, pub web_slug: String,
/// Potential content for this movie /// Potential content for this movie.
#[sea_orm(has_one)] #[sea_orm(has_one)]
pub content: HasOne<entity::content::movies::Entity>, pub content: HasOne<entity::content::movies::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Show entity /// Show entity.
pub mod shows { pub mod shows {
use flix_model::id::ShowId; use flix_model::id::ShowId;
@@ -94,50 +96,51 @@ pub mod shows {
use crate::entity; use crate::entity;
/// The database representation of a flix show /// The database representation of a flix show.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_info_shows")] #[sea_orm(table_name = "flix_info_shows")]
pub struct Model { pub struct Model {
/// The show's ID /// The show's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: ShowId, pub id: ShowId,
/// The show's title /// The show's title.
pub title: String, pub title: String,
/// The show's tagline /// The show's tagline.
pub tagline: String, pub tagline: String,
/// The show's overview /// The show's overview.
pub overview: String, pub overview: String,
/// The show's air date /// The show's air date.
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub date: NaiveDate, pub date: NaiveDate,
/// The sortable title /// The sortable title.
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub sort_title: String, pub sort_title: String,
/// The filesystem-safe slug /// The filesystem-safe slug.
#[sea_orm(indexed, unique)] #[sea_orm(indexed, unique)]
pub fs_slug: String, pub fs_slug: String,
/// The url-safe slug /// The url-safe slug.
#[sea_orm(indexed, unique)] #[sea_orm(indexed, unique)]
pub web_slug: String, pub web_slug: String,
/// Seasons that are part of this show /// Seasons that are part of this show.
#[sea_orm(has_many)] #[sea_orm(has_many)]
pub seasons: HasMany<super::seasons::Entity>, pub seasons: HasMany<super::seasons::Entity>,
/// Episodes that are part of this show /// Episodes that are part of this show.
#[sea_orm(has_many)] #[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>, pub episodes: HasMany<super::episodes::Entity>,
/// Potential content for this show /// Potential content for this show.
#[sea_orm(has_one)] #[sea_orm(has_one)]
pub content: HasOne<entity::content::shows::Entity>, pub content: HasOne<entity::content::shows::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Season entity /// Season entity.
pub mod seasons { pub mod seasons {
use flix_model::id::ShowId; use flix_model::id::ShowId;
use flix_model::numbers::SeasonNumber; use flix_model::numbers::SeasonNumber;
@@ -147,26 +150,26 @@ pub mod seasons {
use crate::entity; use crate::entity;
/// The database representation of a flix season /// The database representation of a flix season.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_info_seasons")] #[sea_orm(table_name = "flix_info_seasons")]
pub struct Model { pub struct Model {
/// The season's show's ID /// The season's show's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub show_id: ShowId, pub show_id: ShowId,
/// The season's number /// The season's number.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub season_number: SeasonNumber, pub season_number: SeasonNumber,
/// The season's title /// The season's title.
pub title: String, pub title: String,
/// The season's overview /// The season's overview.
pub overview: String, pub overview: String,
/// The season's air date /// The season's air date.
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub date: NaiveDate, pub date: NaiveDate,
/// The show this season belongs to /// The show this season belongs to.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "show_id", from = "show_id",
@@ -175,19 +178,20 @@ pub mod seasons {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub show: HasOne<super::shows::Entity>, pub show: HasOne<super::shows::Entity>,
/// Episodes that are part of this season /// Episodes that are part of this season.
#[sea_orm(has_many)] #[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>, pub episodes: HasMany<super::episodes::Entity>,
/// Potential content for this season /// Potential content for this season.
#[sea_orm(has_one)] #[sea_orm(has_one)]
pub content: HasOne<entity::content::seasons::Entity>, pub content: HasOne<entity::content::seasons::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Episode entity /// Episode entity.
pub mod episodes { pub mod episodes {
use flix_model::id::ShowId; use flix_model::id::ShowId;
use flix_model::numbers::{EpisodeNumber, SeasonNumber}; use flix_model::numbers::{EpisodeNumber, SeasonNumber};
@@ -197,29 +201,29 @@ pub mod episodes {
use crate::entity; use crate::entity;
/// The database representation of a flix episode /// The database representation of a flix episode.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_info_episodes")] #[sea_orm(table_name = "flix_info_episodes")]
pub struct Model { pub struct Model {
/// The episode's show's ID /// The episode's show's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub show_id: ShowId, pub show_id: ShowId,
/// The episode's season's number /// The episode's season's number.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub season_number: SeasonNumber, pub season_number: SeasonNumber,
/// The episode's number /// The episode's number.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub episode_number: EpisodeNumber, pub episode_number: EpisodeNumber,
/// The episode's title /// The episode's title.
pub title: String, pub title: String,
/// The episode's overview /// The episode's overview.
pub overview: String, pub overview: String,
/// The episode's air date /// The episode's air date.
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub date: NaiveDate, pub date: NaiveDate,
/// The show this episode belongs to /// The show this episode belongs to.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "show_id", from = "show_id",
@@ -228,7 +232,7 @@ pub mod episodes {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub show: HasOne<super::shows::Entity>, pub show: HasOne<super::shows::Entity>,
/// The season this episode belongs to /// The season this episode belongs to.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "(show_id, season_number)", from = "(show_id, season_number)",
@@ -238,101 +242,112 @@ pub mod episodes {
)] )]
pub season: HasOne<super::seasons::Entity>, pub season: HasOne<super::seasons::Entity>,
/// Potential content for this episode /// Potential content for this episode.
#[sea_orm(has_one)] #[sea_orm(has_one)]
pub content: HasOne<entity::content::episodes::Entity>, pub content: HasOne<entity::content::episodes::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Macros for creating info entities /// Macros for creating info entities.
#[cfg(test)] #[cfg(test)]
pub mod test { pub mod test {
macro_rules! make_info_collection { macro_rules! make_info_collection {
($db:expr, $id:expr) => { ($db:expr, $id:expr) => {
$crate::entity::info::collections::ActiveModel { drop(
id: Set(::flix_model::id::CollectionId::from_raw($id)), $crate::entity::info::collections::ActiveModel {
title: Set(::std::string::String::new()), id: Set(::flix_model::id::CollectionId::from_raw($id)),
overview: Set(::std::string::String::new()), title: Set(::alloc::string::String::new()),
sort_title: Set(::std::string::String::new()), overview: Set(::alloc::string::String::new()),
fs_slug: Set(format!("C FS {}", $id)), sort_title: Set(::alloc::string::String::new()),
web_slug: Set(format!("C Web {}", $id)), fs_slug: Set(format!("C FS {}", $id)),
} web_slug: Set(format!("C Web {}", $id)),
.insert($db) }
.await .insert($db)
.expect("insert"); .await
.expect("insert"),
);
}; };
} }
pub(crate) use make_info_collection; pub(crate) use make_info_collection;
macro_rules! make_info_movie { macro_rules! make_info_movie {
($db:expr, $id:expr) => { ($db:expr, $id:expr) => {
$crate::entity::info::movies::ActiveModel { drop(
id: Set(::flix_model::id::MovieId::from_raw($id)), $crate::entity::info::movies::ActiveModel {
title: Set(::std::string::String::new()), id: Set(::flix_model::id::MovieId::from_raw($id)),
tagline: Set(::std::string::String::new()), title: Set(::alloc::string::String::new()),
overview: Set(::std::string::String::new()), tagline: Set(::alloc::string::String::new()),
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")), overview: Set(::alloc::string::String::new()),
sort_title: Set(::std::string::String::new()), date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
fs_slug: Set(format!("M FS {}", $id)), sort_title: Set(::alloc::string::String::new()),
web_slug: Set(format!("M Web {}", $id)), fs_slug: Set(format!("M FS {}", $id)),
} web_slug: Set(format!("M Web {}", $id)),
.insert($db) }
.await .insert($db)
.expect("insert"); .await
.expect("insert"),
);
}; };
} }
pub(crate) use make_info_movie; pub(crate) use make_info_movie;
macro_rules! make_info_show { macro_rules! make_info_show {
($db:expr, $id:expr) => { ($db:expr, $id:expr) => {
$crate::entity::info::shows::ActiveModel { drop(
id: Set(::flix_model::id::ShowId::from_raw($id)), $crate::entity::info::shows::ActiveModel {
title: Set(::std::string::String::new()), id: Set(::flix_model::id::ShowId::from_raw($id)),
tagline: Set(::std::string::String::new()), title: Set(::alloc::string::String::new()),
overview: Set(::std::string::String::new()), tagline: Set(::alloc::string::String::new()),
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")), overview: Set(::alloc::string::String::new()),
sort_title: Set(::std::string::String::new()), date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
fs_slug: Set(format!("S FS {}", $id)), sort_title: Set(::alloc::string::String::new()),
web_slug: Set(format!("S Web {}", $id)), fs_slug: Set(format!("S FS {}", $id)),
} web_slug: Set(format!("S Web {}", $id)),
.insert($db) }
.await .insert($db)
.expect("insert"); .await
.expect("insert"),
);
}; };
} }
pub(crate) use make_info_show; pub(crate) use make_info_show;
macro_rules! make_info_season { macro_rules! make_info_season {
($db:expr, $show:expr, $season:expr) => { ($db:expr, $show:expr, $season:expr) => {
$crate::entity::info::seasons::ActiveModel { drop(
show_id: Set(::flix_model::id::ShowId::from_raw($show)), $crate::entity::info::seasons::ActiveModel {
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)), show_id: Set(::flix_model::id::ShowId::from_raw($show)),
title: Set(::std::string::String::new()), season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
overview: Set(::std::string::String::new()), title: Set(::alloc::string::String::new()),
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")), overview: Set(::alloc::string::String::new()),
} date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
.insert($db) }
.await .insert($db)
.expect("insert"); .await
.expect("insert"),
);
}; };
} }
pub(crate) use make_info_season; pub(crate) use make_info_season;
macro_rules! make_info_episode { macro_rules! make_info_episode {
($db:expr, $show:expr, $season:expr, $episode:expr) => { ($db:expr, $show:expr, $season:expr, $episode:expr) => {
$crate::entity::info::episodes::ActiveModel { drop(
show_id: Set(::flix_model::id::ShowId::from_raw($show)), $crate::entity::info::episodes::ActiveModel {
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)), show_id: Set(::flix_model::id::ShowId::from_raw($show)),
episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)), season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
title: Set(::std::string::String::new()), episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
overview: Set(::std::string::String::new()), title: Set(::alloc::string::String::new()),
date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")), overview: Set(::alloc::string::String::new()),
} date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
.insert($db) }
.await .insert($db)
.expect("insert"); .await
.expect("insert"),
);
}; };
} }
pub(crate) use make_info_episode; 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, make_info_collection, make_info_episode, make_info_movie, make_info_season, make_info_show,
}; };
#[cfg(not(miri))]
#[tokio::test] #[tokio::test]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn use_test_macros() { async fn use_test_macros() {
let db = new_initialized_memory_db().await; let db = new_initialized_memory_db().await;
@@ -366,8 +384,10 @@ mod tests {
make_info_episode!(&db, 1, 1, 1); make_info_episode!(&db, 1, 1, 1);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_collection { macro_rules! assert_collection {
@@ -409,8 +429,10 @@ mod tests {
assert_collection!(&db, 8, NotNullViolation; web_slug); assert_collection!(&db, 8, NotNullViolation; web_slug);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_movie { macro_rules! assert_movie {
@@ -458,8 +480,10 @@ mod tests {
assert_movie!(&db, 10, NotNullViolation; web_slug); assert_movie!(&db, 10, NotNullViolation; web_slug);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_show { macro_rules! assert_show {
@@ -510,8 +534,11 @@ mod tests {
assert_show!(&db, 10, NotNullViolation; web_slug); assert_show!(&db, 10, NotNullViolation; web_slug);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_season { macro_rules! assert_season {
@@ -561,8 +588,11 @@ mod tests {
assert_season!(&db, 1, 7, NotNullViolation; date); assert_season!(&db, 1, 7, NotNullViolation; date);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_episode { macro_rules! assert_episode {
+113 -63
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 content;
pub mod info; pub mod info;
@@ -13,8 +19,6 @@ mod tests {
use flix_tmdb::model::id::TmdbRepr as TmdbReprId; use flix_tmdb::model::id::TmdbRepr as TmdbReprId;
use sea_orm::ActiveValue::Set; use sea_orm::ActiveValue::Set;
use sea_orm::DatabaseConnection;
use sea_orm::DbErr;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use sea_orm::sqlx::error::ErrorKind; use sea_orm::sqlx::error::ErrorKind;
use seamantic::model::id::SeaOrmRepr as SeaOrmReprId; use seamantic::model::id::SeaOrmRepr as SeaOrmReprId;
@@ -32,35 +36,36 @@ mod tests {
use crate::tests::new_initialized_memory_db; use crate::tests::new_initialized_memory_db;
#[derive(Debug)] #[derive(Debug)]
pub enum ErrorKindError { pub(crate) enum ErrorKindNot {
NotRuntimeError, Runtime,
NotSqlxError, Sqlx,
NotDatabaseError, Database,
} }
pub fn get_error_kind(error: DbErr) -> Result<ErrorKind, ErrorKindError> { /// Extract the error kind from a [`DbErr`].
let runtime_err = match error { ///
DbErr::Conn(runtime_err) => runtime_err, /// # Errors
DbErr::Exec(runtime_err) => runtime_err, /// If the [`DbErr`] internals diverge from [`SqlxError::Database`].
DbErr::Query(runtime_err) => runtime_err, pub(crate) fn get_error_kind(error: DbErr) -> Result<ErrorKind, ErrorKindNot> {
_ => return Err(ErrorKindError::NotRuntimeError), 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 { let RuntimeErr::SqlxError(sqlx_err) = runtime_err else {
sea_orm::RuntimeErr::SqlxError(sqlx_err) => sqlx_err, return Err(ErrorKindNot::Sqlx);
_ => return Err(ErrorKindError::NotSqlxError),
}; };
let database_err = match sqlx_err.as_ref() { let SqlxError::Database(database_err) = sqlx_err.as_ref() else {
sea_orm::SqlxError::Database(database_err) => database_err, return Err(ErrorKindNot::Database);
_ => return Err(ErrorKindError::NotDatabaseError),
}; };
Ok(database_err.kind()) Ok(database_err.kind())
} }
/// Helper macro for writing tests for `ActiveModel` structs where /// Helper macro for writing tests for `ActiveModel` structs where
/// toggling [sea_orm::ActiveValue] is needed /// toggling [`sea_orm::ActiveValue`] is needed.
macro_rules! notsettable { macro_rules! notsettable {
($field:ident, $value:expr $(, $($skip:ident),+)?) => { ($field:ident, $value:expr $(, $($skip:ident),+)?) => {
if notsettable!(@skip, $field $(, $($skip),+)?) { if notsettable!(@skip, $field $(, $($skip),+)?) {
@@ -76,7 +81,7 @@ mod tests {
pub(super) use notsettable; pub(super) use notsettable;
/// Helper macro for writing tests for `ActiveModel` structs where /// Helper macro for writing tests for `ActiveModel` structs where
/// toggling [sea_orm::ActiveValue] is needed /// toggling [`sea_orm::ActiveValue`] is needed.
macro_rules! noneable { macro_rules! noneable {
($field:ident, $value:expr $(, $($skip:ident),+)?) => { ($field:ident, $value:expr $(, $($skip:ident),+)?) => {
if noneable!(@skip, $field $(, $($skip),+)?) { 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 { async fn initialize_deletion_test_database(id: &DbId) -> DatabaseConnection {
let db = new_initialized_memory_db().await; let db = new_initialized_memory_db().await;
@@ -284,12 +294,14 @@ mod tests {
db db
} }
#[cfg(not(miri))]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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, id.flix.collection,
)) ))
.exec(&db) .exec(&db)
@@ -368,12 +380,14 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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, id.flix.movie,
)) ))
.exec(&db) .exec(&db)
@@ -452,15 +466,19 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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(
.exec(&db) id.flix.show,
.await ))
.expect("Entity::delete_by_id"); .exec(&db)
.await
.expect("Entity::delete_by_id");
assert_eq!( assert_eq!(
Ok(1), Ok(1),
@@ -534,12 +552,14 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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), seamantic::model::id::Id::from_raw(id.flix.show),
SeasonNumber::new(id.flix.season), SeasonNumber::new(id.flix.season),
)) ))
@@ -619,12 +639,14 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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), seamantic::model::id::Id::from_raw(id.flix.show),
SeasonNumber::new(id.flix.season), SeasonNumber::new(id.flix.season),
EpisodeNumber::new(id.flix.episode), EpisodeNumber::new(id.flix.episode),
@@ -705,13 +727,15 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[cfg(feature = "tmdb")] #[cfg(feature = "tmdb")]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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, id.tmdb.collection,
)) ))
.exec(&db) .exec(&db)
@@ -790,13 +814,15 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[cfg(feature = "tmdb")] #[cfg(feature = "tmdb")]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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, id.tmdb.movie,
)) ))
.exec(&db) .exec(&db)
@@ -875,16 +901,20 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[cfg(feature = "tmdb")] #[cfg(feature = "tmdb")]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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(
.exec(&db) id.tmdb.show,
.await ))
.expect("Entity::delete_by_id"); .exec(&db)
.await
.expect("Entity::delete_by_id");
assert_eq!( assert_eq!(
Ok(1), Ok(1),
@@ -958,13 +988,15 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[cfg(feature = "tmdb")] #[cfg(feature = "tmdb")]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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), flix_tmdb::model::id::Id::from_raw(id.tmdb.show),
SeasonNumber::new(id.tmdb.season), SeasonNumber::new(id.tmdb.season),
)) ))
@@ -1044,13 +1076,15 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[cfg(feature = "tmdb")] #[cfg(feature = "tmdb")]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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), flix_tmdb::model::id::Id::from_raw(id.tmdb.show),
SeasonNumber::new(id.tmdb.season), SeasonNumber::new(id.tmdb.season),
EpisodeNumber::new(id.tmdb.episode), EpisodeNumber::new(id.tmdb.episode),
@@ -1131,12 +1165,14 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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, id.content.library,
)) ))
.exec(&db) .exec(&db)
@@ -1215,12 +1251,14 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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, id.flix.collection,
)) ))
.exec(&db) .exec(&db)
@@ -1299,12 +1337,14 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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, id.flix.movie,
)) ))
.exec(&db) .exec(&db)
@@ -1383,12 +1423,14 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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, id.flix.show,
)) ))
.exec(&db) .exec(&db)
@@ -1467,12 +1509,14 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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), seamantic::model::id::Id::from_raw(id.flix.show),
SeasonNumber::new(id.flix.season), SeasonNumber::new(id.flix.season),
)) ))
@@ -1552,12 +1596,14 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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), seamantic::model::id::Id::from_raw(id.flix.show),
SeasonNumber::new(id.flix.season), SeasonNumber::new(id.flix.season),
EpisodeNumber::new(id.flix.episode), EpisodeNumber::new(id.flix.episode),
@@ -1638,12 +1684,14 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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), seamantic::model::id::Id::from_raw(id.flix.movie),
id.watch.user, id.watch.user,
)) ))
@@ -1723,12 +1771,14 @@ mod tests {
); );
} }
#[cfg(not(miri))]
#[tokio::test] #[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 id = DbId::default();
let db = initialize_deletion_test_database(&id).await; 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), seamantic::model::id::Id::from_raw(id.flix.show),
SeasonNumber::new(id.flix.season), SeasonNumber::new(id.flix.season),
EpisodeNumber::new(id.flix.episode), 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 { pub mod collections {
use flix_model::id::CollectionId as FlixId; use flix_model::id::CollectionId as FlixId;
use flix_tmdb::model::id::CollectionId; use flix_tmdb::model::id::CollectionId;
@@ -10,23 +10,23 @@ pub mod collections {
use crate::entity; use crate::entity;
/// The database representation of a tmdb collection /// The database representation of a tmdb collection.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_tmdb_collections")] #[sea_orm(table_name = "flix_tmdb_collections")]
pub struct Model { pub struct Model {
/// The collection's TMDB ID /// The collection's TMDB ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub tmdb_id: CollectionId, pub tmdb_id: CollectionId,
/// The collection's ID /// The collection's ID.
#[sea_orm(unique)] #[sea_orm(unique)]
pub flix_id: FlixId, pub flix_id: FlixId,
/// The date of the last update /// The date of the last update.
pub last_update: DateTime<Utc>, pub last_update: DateTime<Utc>,
/// The number of movies in the collection /// The number of movies in the collection.
pub movie_count: u16, pub movie_count: u16,
/// The info for this collection /// The info for this collection.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "flix_id", from = "flix_id",
@@ -36,15 +36,16 @@ pub mod collections {
)] )]
pub info: HasOne<entity::info::collections::Entity>, pub info: HasOne<entity::info::collections::Entity>,
/// Movies that are in this collection /// Movies that are in this collection.
#[sea_orm(has_many)] #[sea_orm(has_many)]
pub movies: HasMany<super::movies::Entity>, pub movies: HasMany<super::movies::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Movie entity /// Movie entity.
pub mod movies { pub mod movies {
use flix_model::id::MovieId as FlixId; use flix_model::id::MovieId as FlixId;
use flix_tmdb::model::id::{CollectionId, MovieId}; use flix_tmdb::model::id::{CollectionId, MovieId};
@@ -56,26 +57,26 @@ pub mod movies {
use crate::entity; use crate::entity;
/// The database representation of a tmdb movie /// The database representation of a tmdb movie.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_tmdb_movies")] #[sea_orm(table_name = "flix_tmdb_movies")]
pub struct Model { pub struct Model {
/// The movie's TMDB ID /// The movie's TMDB ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub tmdb_id: MovieId, pub tmdb_id: MovieId,
/// The movie's ID /// The movie's ID.
#[sea_orm(unique)] #[sea_orm(unique)]
pub flix_id: FlixId, pub flix_id: FlixId,
/// The date of the last update /// The date of the last update.
pub last_update: DateTime<Utc>, pub last_update: DateTime<Utc>,
/// The movie's runtime in seconds /// The movie's runtime in seconds.
pub runtime: Seconds, pub runtime: Seconds,
/// The TMDB ID of the collection this movie belongs to /// The TMDB ID of the collection this movie belongs to.
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub collection_id: Option<CollectionId>, pub collection_id: Option<CollectionId>,
/// The collection this movie belongs to /// The collection this movie belongs to.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "collection_id", from = "collection_id",
@@ -84,7 +85,7 @@ pub mod movies {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub collection: HasOne<super::collections::Entity>, pub collection: HasOne<super::collections::Entity>,
/// The info for this movie /// The info for this movie.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "flix_id", from = "flix_id",
@@ -95,10 +96,11 @@ pub mod movies {
pub info: HasOne<entity::info::movies::Entity>, pub info: HasOne<entity::info::movies::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Show entity /// Show entity.
pub mod shows { pub mod shows {
use flix_model::id::ShowId as FlixId; use flix_model::id::ShowId as FlixId;
use flix_tmdb::model::id::ShowId; use flix_tmdb::model::id::ShowId;
@@ -108,23 +110,23 @@ pub mod shows {
use crate::entity; use crate::entity;
/// The database representation of a tmdb show /// The database representation of a tmdb show.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_tmdb_shows")] #[sea_orm(table_name = "flix_tmdb_shows")]
pub struct Model { pub struct Model {
/// The show's TMDB ID /// The show's TMDB ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub tmdb_id: ShowId, pub tmdb_id: ShowId,
/// The show's ID /// The show's ID.
#[sea_orm(unique)] #[sea_orm(unique)]
pub flix_id: FlixId, pub flix_id: FlixId,
/// The movie's runtime in seconds /// The movie's runtime in seconds.
pub last_update: DateTime<Utc>, pub last_update: DateTime<Utc>,
/// The number of seasons the show has /// The number of seasons the show has.
pub number_of_seasons: u32, pub number_of_seasons: u32,
/// The info for this show /// The info for this show.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "flix_id", from = "flix_id",
@@ -134,18 +136,19 @@ pub mod shows {
)] )]
pub info: HasOne<entity::info::shows::Entity>, 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)] #[sea_orm(has_many)]
pub seasons: HasMany<super::seasons::Entity>, pub seasons: HasMany<super::seasons::Entity>,
/// Episodes that are part of this show /// Episodes that are part of this show.
#[sea_orm(has_many)] #[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>, pub episodes: HasMany<super::episodes::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Season entity /// Season entity.
pub mod seasons { pub mod seasons {
use flix_model::id::ShowId as FlixId; use flix_model::id::ShowId as FlixId;
use flix_model::numbers::SeasonNumber; use flix_model::numbers::SeasonNumber;
@@ -156,27 +159,27 @@ pub mod seasons {
use crate::entity; use crate::entity;
/// The database representation of a tmdb season /// The database representation of a tmdb season.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_tmdb_seasons")] #[sea_orm(table_name = "flix_tmdb_seasons")]
pub struct Model { pub struct Model {
/// The season's show's TMDB ID /// The season's show's TMDB ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub tmdb_show: ShowId, pub tmdb_show: ShowId,
/// The season's TMDB season number /// The season's TMDB season number.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub tmdb_season: SeasonNumber, pub tmdb_season: SeasonNumber,
/// The season's show's ID /// The season's show's ID.
#[sea_orm(unique_key = "flix")] #[sea_orm(unique_key = "flix")]
pub flix_show: FlixId, pub flix_show: FlixId,
/// The season's number /// The season's number.
#[sea_orm(unique_key = "flix")] #[sea_orm(unique_key = "flix")]
pub flix_season: SeasonNumber, pub flix_season: SeasonNumber,
/// The date of the last update /// The date of the last update.
pub last_update: DateTime<Utc>, pub last_update: DateTime<Utc>,
/// The show this season belongs to /// The show this season belongs to.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "tmdb_show", from = "tmdb_show",
@@ -185,7 +188,7 @@ pub mod seasons {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub show: HasOne<super::shows::Entity>, pub show: HasOne<super::shows::Entity>,
/// The info for this season /// The info for this season.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "(flix_show, flix_season)", from = "(flix_show, flix_season)",
@@ -195,15 +198,16 @@ pub mod seasons {
)] )]
pub info: HasOne<entity::info::seasons::Entity>, 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)] #[sea_orm(has_many)]
pub episodes: HasMany<super::episodes::Entity>, pub episodes: HasMany<super::episodes::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Season entity /// Season entity.
pub mod episodes { pub mod episodes {
use flix_model::id::ShowId as FlixId; use flix_model::id::ShowId as FlixId;
use flix_model::numbers::{EpisodeNumber, SeasonNumber}; use flix_model::numbers::{EpisodeNumber, SeasonNumber};
@@ -215,35 +219,35 @@ pub mod episodes {
use crate::entity; use crate::entity;
/// The database representation of a tmdb episode /// The database representation of a tmdb episode.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_tmdb_episodes")] #[sea_orm(table_name = "flix_tmdb_episodes")]
pub struct Model { pub struct Model {
/// The episode's show's TMDB ID /// The episode's show's TMDB ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub tmdb_show: ShowId, 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)] #[sea_orm(primary_key, auto_increment = false)]
pub tmdb_season: SeasonNumber, pub tmdb_season: SeasonNumber,
/// The episode's TMDB episode number /// The episode's TMDB episode number.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub tmdb_episode: EpisodeNumber, pub tmdb_episode: EpisodeNumber,
/// The episode's show's ID /// The episode's show's ID.
#[sea_orm(unique_key = "flix")] #[sea_orm(unique_key = "flix")]
pub flix_show: FlixId, pub flix_show: FlixId,
/// The episode's season's number /// The episode's season's number.
#[sea_orm(unique_key = "flix")] #[sea_orm(unique_key = "flix")]
pub flix_season: SeasonNumber, pub flix_season: SeasonNumber,
/// The episode's number /// The episode's number.
#[sea_orm(unique_key = "flix")] #[sea_orm(unique_key = "flix")]
pub flix_episode: EpisodeNumber, pub flix_episode: EpisodeNumber,
/// The date of the last update /// The date of the last update.
pub last_update: DateTime<Utc>, pub last_update: DateTime<Utc>,
/// The episode's runtime in seconds /// The episode's runtime in seconds.
pub runtime: Seconds, pub runtime: Seconds,
/// The show this episode belongs to /// The show this episode belongs to.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "tmdb_show", from = "tmdb_show",
@@ -252,7 +256,7 @@ pub mod episodes {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub show: HasOne<super::shows::Entity>, pub show: HasOne<super::shows::Entity>,
/// The season this episode belongs to /// The season this episode belongs to.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "(tmdb_show, tmdb_season)", from = "(tmdb_show, tmdb_season)",
@@ -261,7 +265,7 @@ pub mod episodes {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub season: HasOne<super::seasons::Entity>, pub season: HasOne<super::seasons::Entity>,
/// The info for this episode /// The info for this episode.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "(flix_show, flix_season, flix_episode)", from = "(flix_show, flix_season, flix_episode)",
@@ -272,15 +276,16 @@ pub mod episodes {
pub info: HasOne<entity::info::episodes::Entity>, pub info: HasOne<entity::info::episodes::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Macros for creating tmdb entities /// Macros for creating tmdb entities.
#[cfg(test)] #[cfg(test)]
pub mod test { pub mod test {
macro_rules! make_tmdb_collection { macro_rules! make_tmdb_collection {
($db:expr, $id:expr, $flix_id:expr) => { ($db:expr, $id:expr, $flix_id:expr) => {
$crate::entity::tmdb::collections::ActiveModel { _ = $crate::entity::tmdb::collections::ActiveModel {
tmdb_id: Set(::flix_tmdb::model::id::CollectionId::from_raw($id)), tmdb_id: Set(::flix_tmdb::model::id::CollectionId::from_raw($id)),
flix_id: Set(::flix_model::id::CollectionId::from_raw($flix_id)), flix_id: Set(::flix_model::id::CollectionId::from_raw($flix_id)),
last_update: Set(::chrono::Utc::now()), last_update: Set(::chrono::Utc::now()),
@@ -295,7 +300,7 @@ pub mod test {
macro_rules! make_tmdb_movie { macro_rules! make_tmdb_movie {
($db:expr, $id:expr, $flix_id:expr) => { ($db:expr, $id:expr, $flix_id:expr) => {
$crate::entity::tmdb::movies::ActiveModel { _ = $crate::entity::tmdb::movies::ActiveModel {
tmdb_id: Set(::flix_tmdb::model::id::MovieId::from_raw($id)), tmdb_id: Set(::flix_tmdb::model::id::MovieId::from_raw($id)),
flix_id: Set(::flix_model::id::MovieId::from_raw($flix_id)), flix_id: Set(::flix_model::id::MovieId::from_raw($flix_id)),
last_update: Set(::chrono::Utc::now()), last_update: Set(::chrono::Utc::now()),
@@ -311,7 +316,7 @@ pub mod test {
macro_rules! make_tmdb_show { macro_rules! make_tmdb_show {
($db:expr, $id:expr, $flix_id:expr) => { ($db:expr, $id:expr, $flix_id:expr) => {
$crate::entity::tmdb::shows::ActiveModel { _ = $crate::entity::tmdb::shows::ActiveModel {
tmdb_id: Set(::flix_tmdb::model::id::ShowId::from_raw($id)), tmdb_id: Set(::flix_tmdb::model::id::ShowId::from_raw($id)),
flix_id: Set(::flix_model::id::ShowId::from_raw($flix_id)), flix_id: Set(::flix_model::id::ShowId::from_raw($flix_id)),
last_update: Set(::chrono::Utc::now()), last_update: Set(::chrono::Utc::now()),
@@ -326,7 +331,7 @@ pub mod test {
macro_rules! make_tmdb_season { macro_rules! make_tmdb_season {
($db:expr, $show:expr, $season:expr, $flix_show:expr, $flix_season:expr) => { ($db:expr, $show:expr, $season:expr, $flix_show:expr, $flix_season:expr) => {
$crate::entity::tmdb::seasons::ActiveModel { _ = $crate::entity::tmdb::seasons::ActiveModel {
tmdb_show: Set(::flix_tmdb::model::id::ShowId::from_raw($show)), tmdb_show: Set(::flix_tmdb::model::id::ShowId::from_raw($show)),
tmdb_season: Set(::flix_model::numbers::SeasonNumber::new($season)), tmdb_season: Set(::flix_model::numbers::SeasonNumber::new($season)),
flix_show: Set(::flix_model::id::ShowId::from_raw($flix_show)), flix_show: Set(::flix_model::id::ShowId::from_raw($flix_show)),
@@ -342,7 +347,7 @@ pub mod test {
macro_rules! make_tmdb_episode { macro_rules! make_tmdb_episode {
($db:expr, $show:expr, $season:expr, $episode:expr, $flix_show:expr, $flix_season:expr, $flix_episode:expr) => { ($db:expr, $show:expr, $season:expr, $episode:expr, $flix_show:expr, $flix_season:expr, $flix_episode:expr) => {
$crate::entity::tmdb::episodes::ActiveModel { _ = $crate::entity::tmdb::episodes::ActiveModel {
tmdb_show: Set(::flix_tmdb::model::id::ShowId::from_raw($show)), tmdb_show: Set(::flix_tmdb::model::id::ShowId::from_raw($show)),
tmdb_season: Set(::flix_model::numbers::SeasonNumber::new($season)), tmdb_season: Set(::flix_model::numbers::SeasonNumber::new($season)),
tmdb_episode: Set(::flix_model::numbers::EpisodeNumber::new($episode)), 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, make_tmdb_collection, make_tmdb_episode, make_tmdb_movie, make_tmdb_season, make_tmdb_show,
}; };
#[cfg(not(miri))]
#[tokio::test] #[tokio::test]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn use_test_macros() { async fn use_test_macros() {
let db = new_initialized_memory_db().await; let db = new_initialized_memory_db().await;
@@ -402,8 +410,11 @@ mod tests {
make_tmdb_episode!(&db, 1, 1, 1, 1, 1, 1); make_tmdb_episode!(&db, 1, 1, 1, 1, 1, 1);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_collection { macro_rules! assert_collection {
@@ -449,8 +460,11 @@ mod tests {
assert_collection!(&db, 6, 6, NotNullViolation; movie_count); assert_collection!(&db, 6, 6, NotNullViolation; movie_count);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_movie { macro_rules! assert_movie {
@@ -502,8 +516,11 @@ mod tests {
assert_movie!(&db, 7, 7, None, ForeignKeyViolation; collection_id); assert_movie!(&db, 7, 7, None, ForeignKeyViolation; collection_id);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_show { macro_rules! assert_show {
@@ -552,8 +569,11 @@ mod tests {
assert_show!(&db, 6, 6, NotNullViolation; number_of_seasons); assert_show!(&db, 6, 6, NotNullViolation; number_of_seasons);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_season { macro_rules! assert_season {
@@ -607,8 +627,11 @@ mod tests {
assert_season!(&db, 1, 7, 1, 7, NotNullViolation; last_update); assert_season!(&db, 1, 7, 1, 7, NotNullViolation; last_update);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_episode { 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 { pub mod collections {
use flix_model::id::{CollectionId, RawId}; use flix_model::id::{CollectionId, RawId};
@@ -9,21 +9,21 @@ pub mod collections {
use crate::entity; use crate::entity;
/// The database representation of a watched movie /// The database representation of a watched movie.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_watched_collections")] #[sea_orm(table_name = "flix_watched_collections")]
pub struct Model { pub struct Model {
/// The collection's ID /// The collection's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: CollectionId, pub id: CollectionId,
/// The user's ID /// The user's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub user_id: RawId, pub user_id: RawId,
/// The date this collection was watched /// The date this collection was watched.
pub watched_date: DateTime<Utc>, pub watched_date: DateTime<Utc>,
/// The info for this collection /// The info for this collection.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
relation_enum = "Info", relation_enum = "Info",
@@ -33,15 +33,16 @@ pub mod collections {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub info: HasOne<entity::info::collections::Entity>, pub info: HasOne<entity::info::collections::Entity>,
/// The content for this collection /// The content for this collection.
#[sea_orm(belongs_to, relation_enum = "Content", from = "id", to = "id", skip_fk)] #[sea_orm(belongs_to, relation_enum = "Content", from = "id", to = "id", skip_fk)]
pub content: HasOne<entity::content::collections::Entity>, pub content: HasOne<entity::content::collections::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Movie entity /// Movie entity.
pub mod movies { pub mod movies {
use flix_model::id::{MovieId, RawId}; use flix_model::id::{MovieId, RawId};
@@ -50,21 +51,21 @@ pub mod movies {
use crate::entity; use crate::entity;
/// The database representation of a watched movie /// The database representation of a watched movie.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_watched_movies")] #[sea_orm(table_name = "flix_watched_movies")]
pub struct Model { pub struct Model {
/// The movie's ID /// The movie's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: MovieId, pub id: MovieId,
/// The user's ID /// The user's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub user_id: RawId, pub user_id: RawId,
/// The date this movie was watched /// The date this movie was watched.
pub watched_date: DateTime<Utc>, pub watched_date: DateTime<Utc>,
/// The info for this movie /// The info for this movie.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "id", from = "id",
@@ -73,15 +74,16 @@ pub mod movies {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub info: HasOne<entity::info::movies::Entity>, pub info: HasOne<entity::info::movies::Entity>,
/// The content for this movie /// The content for this movie.
#[sea_orm(belongs_to, relation_enum = "Content", from = "id", to = "id", skip_fk)] #[sea_orm(belongs_to, relation_enum = "Content", from = "id", to = "id", skip_fk)]
pub content: HasOne<entity::content::movies::Entity>, pub content: HasOne<entity::content::movies::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Show entity /// Show entity.
pub mod shows { pub mod shows {
use flix_model::id::{RawId, ShowId}; use flix_model::id::{RawId, ShowId};
@@ -90,21 +92,21 @@ pub mod shows {
use crate::entity; use crate::entity;
/// The database representation of a watched movie /// The database representation of a watched movie.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_watched_shows")] #[sea_orm(table_name = "flix_watched_shows")]
pub struct Model { pub struct Model {
/// The show's ID /// The show's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: ShowId, pub id: ShowId,
/// The user's ID /// The user's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub user_id: RawId, pub user_id: RawId,
/// The date this show was watched /// The date this show was watched.
pub watched_date: DateTime<Utc>, pub watched_date: DateTime<Utc>,
/// The info for this show /// The info for this show.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
relation_enum = "Info", relation_enum = "Info",
@@ -114,15 +116,16 @@ pub mod shows {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub info: HasOne<entity::info::shows::Entity>, pub info: HasOne<entity::info::shows::Entity>,
/// The content for this show /// The content for this show.
#[sea_orm(belongs_to, relation_enum = "Content", from = "id", to = "id", skip_fk)] #[sea_orm(belongs_to, relation_enum = "Content", from = "id", to = "id", skip_fk)]
pub content: HasOne<entity::content::shows::Entity>, pub content: HasOne<entity::content::shows::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Season entity /// Season entity.
pub mod seasons { pub mod seasons {
use flix_model::id::{RawId, ShowId}; use flix_model::id::{RawId, ShowId};
use flix_model::numbers::SeasonNumber; use flix_model::numbers::SeasonNumber;
@@ -132,24 +135,24 @@ pub mod seasons {
use crate::entity; use crate::entity;
/// The database representation of a watched movie /// The database representation of a watched movie.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_watched_seasons")] #[sea_orm(table_name = "flix_watched_seasons")]
pub struct Model { pub struct Model {
/// The season's show's ID /// The season's show's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub show_id: ShowId, pub show_id: ShowId,
/// The season's number /// The season's number.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub season_number: SeasonNumber, pub season_number: SeasonNumber,
/// The user's ID /// The user's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub user_id: RawId, pub user_id: RawId,
/// The date this season was watched /// The date this season was watched.
pub watched_date: DateTime<Utc>, pub watched_date: DateTime<Utc>,
/// The info for this season /// The info for this season.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
relation_enum = "Info", relation_enum = "Info",
@@ -159,7 +162,7 @@ pub mod seasons {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub info: HasOne<entity::info::seasons::Entity>, pub info: HasOne<entity::info::seasons::Entity>,
/// The content for this season /// The content for this season.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
relation_enum = "Content", relation_enum = "Content",
@@ -170,10 +173,11 @@ pub mod seasons {
pub content: HasOne<entity::content::seasons::Entity>, pub content: HasOne<entity::content::seasons::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Episode entity /// Episode entity.
pub mod episodes { pub mod episodes {
use flix_model::id::{RawId, ShowId}; use flix_model::id::{RawId, ShowId};
use flix_model::numbers::{EpisodeNumber, SeasonNumber}; use flix_model::numbers::{EpisodeNumber, SeasonNumber};
@@ -183,27 +187,27 @@ pub mod episodes {
use crate::entity; use crate::entity;
/// The database representation of a watched movie /// The database representation of a watched movie.
#[sea_orm::model] #[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)] #[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "flix_watched_episodes")] #[sea_orm(table_name = "flix_watched_episodes")]
pub struct Model { pub struct Model {
/// The episode's show's ID /// The episode's show's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub show_id: ShowId, pub show_id: ShowId,
/// The episode's season's number /// The episode's season's number.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub season_number: SeasonNumber, pub season_number: SeasonNumber,
/// The episode's number /// The episode's number.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub episode_number: EpisodeNumber, pub episode_number: EpisodeNumber,
/// The user's ID /// The user's ID.
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub user_id: RawId, pub user_id: RawId,
/// The date this episode was watched /// The date this episode was watched.
pub watched_date: DateTime<Utc>, pub watched_date: DateTime<Utc>,
/// The info for this episode /// The info for this episode.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
relation_enum = "Info", relation_enum = "Info",
@@ -213,7 +217,7 @@ pub mod episodes {
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub info: HasOne<entity::info::episodes::Entity>, pub info: HasOne<entity::info::episodes::Entity>,
/// The content for this episode /// The content for this episode.
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
relation_enum = "Content", relation_enum = "Content",
@@ -224,15 +228,16 @@ pub mod episodes {
pub content: HasOne<entity::content::episodes::Entity>, pub content: HasOne<entity::content::episodes::Entity>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// Macros for creating watched entities /// Macros for creating watched entities.
#[cfg(test)] #[cfg(test)]
pub mod test { pub mod test {
macro_rules! make_watched_movie { macro_rules! make_watched_movie {
($db:expr, $id:expr, $user:expr) => { ($db:expr, $id:expr, $user:expr) => {
$crate::entity::watched::movies::ActiveModel { _ = $crate::entity::watched::movies::ActiveModel {
id: Set(::flix_model::id::MovieId::from_raw($id)), id: Set(::flix_model::id::MovieId::from_raw($id)),
user_id: Set($user), user_id: Set($user),
watched_date: Set(::chrono::Utc::now()), watched_date: Set(::chrono::Utc::now()),
@@ -246,7 +251,7 @@ pub mod test {
macro_rules! make_watched_episode { macro_rules! make_watched_episode {
($db:expr, $show:expr, $season:expr, $episode:expr, $user:expr) => { ($db:expr, $show:expr, $season:expr, $episode:expr, $user:expr) => {
$crate::entity::watched::episodes::ActiveModel { _ = $crate::entity::watched::episodes::ActiveModel {
show_id: Set(::flix_model::id::ShowId::from_raw($show)), show_id: Set(::flix_model::id::ShowId::from_raw($show)),
season_number: Set(::flix_model::numbers::SeasonNumber::new($season)), season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)), episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
@@ -284,7 +289,10 @@ mod tests {
use super::super::tests::notsettable; use super::super::tests::notsettable;
use super::test::{make_watched_episode, make_watched_movie}; use super::test::{make_watched_episode, make_watched_movie};
#[cfg(not(miri))]
#[tokio::test] #[tokio::test]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "unit test")]
async fn use_test_macros() { async fn use_test_macros() {
let db = new_initialized_memory_db().await; let db = new_initialized_memory_db().await;
@@ -297,8 +305,11 @@ mod tests {
make_watched_episode!(&db, 1, 1, 1, 1); make_watched_episode!(&db, 1, 1, 1, 1);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_movie { macro_rules! assert_movie {
@@ -340,8 +351,11 @@ mod tests {
assert_movie!(&db, 5, 1, NotNullViolation; watched_date); assert_movie!(&db, 5, 1, NotNullViolation; watched_date);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_episode { macro_rules! assert_episode {
@@ -394,13 +408,16 @@ mod tests {
assert_episode!(&db, 7, 1, 1, 1, NotNullViolation; watched_date); assert_episode!(&db, 7, 1, 1, 1, NotNullViolation; watched_date);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_season { macro_rules! assert_season {
($db:expr, $show:literal, $season:literal, $uid:literal, Watched) => { ($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(()) .ok_or(())
.expect("is none"); .expect("is none");
}; };
@@ -462,13 +479,16 @@ mod tests {
assert_season!(&db, 1, 2, 3, Unwatched); assert_season!(&db, 1, 2, 3, Unwatched);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_show { macro_rules! assert_show {
($db:expr, $show:literal, $uid:literal, Watched) => { ($db:expr, $show:literal, $uid:literal, Watched) => {
assert_show!(@find, $db, $show, $uid) _ = assert_show!(@find, $db, $show, $uid)
.ok_or(()) .ok_or(())
.expect("is none"); .expect("is none");
}; };
@@ -517,13 +537,16 @@ mod tests {
assert_show!(&db, 1, 2, Unwatched); assert_show!(&db, 1, 2, Unwatched);
} }
#[cfg(not(miri))]
#[tokio::test] #[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; let db = new_initialized_memory_db().await;
macro_rules! assert_collection { macro_rules! assert_collection {
($db:expr, $id:literal, $uid:literal, Watched) => { ($db:expr, $id:literal, $uid:literal, Watched) => {
assert_collection!(@find, $db, $id, $uid) _ = assert_collection!(@find, $db, $id, $uid)
.ok_or(()) .ok_or(())
.expect("is none"); .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_attr(docsrs, feature(doc_cfg))]
#[cfg(test)]
extern crate alloc;
pub mod connection; pub mod connection;
pub mod entity; pub mod entity;
pub mod migration; pub mod migration;
@@ -12,7 +15,8 @@ mod tests {
use crate::connection::Connection; 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 options = ConnectOptions::new("sqlite::memory:");
let db = Database::connect(options) let db = Database::connect(options)
@@ -1,18 +1,22 @@
//! Custom views for collections.
use sea_orm::prelude::*; use sea_orm::prelude::*;
use sea_orm::sea_query::Table; use sea_orm::sea_query::Table;
use sea_orm::{ConnectionTrait, DbBackend, Statement}; use sea_orm::{DbBackend, Statement};
use sea_orm_migration::prelude::*; 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 manager
.drop_table(Table::drop().table("flix_watched_collections").to_owned()) .drop_table(Table::drop().table("flix_watched_collections").to_owned())
.await?; .await?;
manager _ = manager
.get_connection() .get_connection()
.execute_raw(Statement::from_string( .execute_raw(Statement::from_string(
DbBackend::Sqlite, DbBackend::Sqlite,
r#" "
CREATE VIEW flix_watched_collections AS CREATE VIEW flix_watched_collections AS
WITH RECURSIVE WITH RECURSIVE
watched_items AS ( watched_items AS (
@@ -81,22 +85,24 @@ pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
) )
GROUP BY ci.parent_id, wi.user_id GROUP BY ci.parent_id, wi.user_id
; ;
"#, ",
)) ))
.await?; .await?;
Ok(()) Ok(())
} }
pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { /// # Errors
manager /// Database operations can fail.
pub(crate) async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
_ = manager
.get_connection() .get_connection()
.execute_raw(Statement::from_string( .execute_raw(Statement::from_string(
DbBackend::Sqlite, DbBackend::Sqlite,
r#" "
DROP VIEW flix_watched_collections DROP VIEW flix_watched_collections
; ;
"#, ",
)) ))
.await?; .await?;
@@ -11,10 +11,16 @@ mod collections;
mod seasons; mod seasons;
mod shows; mod shows;
/// The migration entry point.
#[derive(DeriveMigrationName)] #[derive(DeriveMigrationName)]
pub(super) struct Migration; pub(super) struct Migration;
#[async_trait::async_trait] #[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 { impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
seasons::up(manager).await?; seasons::up(manager).await?;
+15 -9
View File
@@ -1,18 +1,22 @@
//! Custom views for seasons.
use sea_orm::prelude::*; use sea_orm::prelude::*;
use sea_orm::sea_query::Table; use sea_orm::sea_query::Table;
use sea_orm::{ConnectionTrait, DbBackend, Statement}; use sea_orm::{DbBackend, Statement};
use sea_orm_migration::prelude::*; 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 manager
.drop_table(Table::drop().table("flix_watched_seasons").to_owned()) .drop_table(Table::drop().table("flix_watched_seasons").to_owned())
.await?; .await?;
manager _ = manager
.get_connection() .get_connection()
.execute_raw(Statement::from_string( .execute_raw(Statement::from_string(
DbBackend::Sqlite, DbBackend::Sqlite,
r#" "
CREATE VIEW flix_watched_seasons AS CREATE VIEW flix_watched_seasons AS
SELECT SELECT
w.show_id, 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 GROUP BY w.show_id, w.season_number, w.user_id
; ;
"#, ",
)) ))
.await?; .await?;
Ok(()) Ok(())
} }
pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { /// # Errors
manager /// Database operations can fail.
pub(crate) async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
_ = manager
.get_connection() .get_connection()
.execute_raw(Statement::from_string( .execute_raw(Statement::from_string(
DbBackend::Sqlite, DbBackend::Sqlite,
r#" "
DROP VIEW flix_watched_seasons DROP VIEW flix_watched_seasons
; ;
"#, ",
)) ))
.await?; .await?;
+15 -9
View File
@@ -1,18 +1,22 @@
//! Custom views for shows.
use sea_orm::prelude::*; use sea_orm::prelude::*;
use sea_orm::sea_query::Table; use sea_orm::sea_query::Table;
use sea_orm::{ConnectionTrait, DbBackend, Statement}; use sea_orm::{DbBackend, Statement};
use sea_orm_migration::prelude::*; 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 manager
.drop_table(Table::drop().table("flix_watched_shows").to_owned()) .drop_table(Table::drop().table("flix_watched_shows").to_owned())
.await?; .await?;
manager _ = manager
.get_connection() .get_connection()
.execute_raw(Statement::from_string( .execute_raw(Statement::from_string(
DbBackend::Sqlite, DbBackend::Sqlite,
r#" "
CREATE VIEW flix_watched_shows AS CREATE VIEW flix_watched_shows AS
SELECT SELECT
w.show_id as id, 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 GROUP BY w.show_id, w.user_id
; ;
"#, ",
)) ))
.await?; .await?;
Ok(()) Ok(())
} }
pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { /// # Errors
manager /// Database operations can fail.
pub(crate) async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
_ = manager
.get_connection() .get_connection()
.execute_raw(Statement::from_string( .execute_raw(Statement::from_string(
DbBackend::Sqlite, DbBackend::Sqlite,
r#" "
DROP VIEW flix_watched_shows DROP VIEW flix_watched_shows
; ;
"#, ",
)) ))
.await?; .await?;
+1 -1
View File
@@ -1,4 +1,4 @@
//! Migrations for maintaining the database schema //! Migrations for maintaining the database schema.
seamantic::migrations! { seamantic::migrations! {
"seaql_migrations_flix"; "seaql_migrations_flix";
+7 -5
View File
@@ -1,14 +1,16 @@
[package] [package]
name = "flix" name = "flix"
version = "0.0.20" version = "0.1.0"
license-file.workspace = true
description = "Mechanisms for interacting with flix media" description = "Mechanisms for interacting with flix media"
repository = "https://github.com/QuantumShade/flix" repository = "https://git.skrundz.dev/quantumshade/flix"
categories = [] keywords = ["flix"]
categories = ["multimedia"]
include = ["/src"]
publish = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs] [package.metadata.docs.rs]
all-features = true 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))] #![cfg_attr(docsrs, feature(doc_cfg))]
+10 -5
View File
@@ -1,14 +1,16 @@
[package] [package]
name = "flix-fs" name = "flix-fs"
version = "0.0.20" version = "0.1.0"
license-file.workspace = true
description = "Filesystem scanner for flix media" description = "Filesystem scanner for flix media"
repository = "https://github.com/QuantumShade/flix" repository = "https://git.skrundz.dev/quantumshade/flix"
categories = [] keywords = ["flix"]
categories = ["multimedia"]
include = ["/src"]
publish = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs] [package.metadata.docs.rs]
all-features = true all-features = true
@@ -23,5 +25,8 @@ thiserror = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tokio-stream = { workspace = true, features = ["fs"] } tokio-stream = { workspace = true, features = ["fs"] }
[dev-dependencies]
tempfile = { workspace = true }
[lints] [lints]
workspace = true workspace = true
+14 -10
View File
@@ -1,39 +1,43 @@
//! Filesystem errors.
use std::io; use std::io;
/// The error type for filesystem scanning operations. /// The error type for filesystem scanning operations.
#[derive(Debug, thiserror::Error)] #[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 { pub enum Error {
/// fs::read_dir failed /// `fs::read_dir` failed.
#[error("fs::read_dir: {0}")] #[error("fs::read_dir: {0}")]
ReadDir(io::Error), ReadDir(io::Error),
/// fs::read_dir::next_entry failed /// `fs::read_dir::next_entry` failed.
#[error("fs::read_dir::next_entry: {0}")] #[error("fs::read_dir::next_entry: {0}")]
ReadDirEntry(io::Error), ReadDirEntry(io::Error),
/// fs::read_dir::file_type failed /// `fs::read_dir::file_type` failed.
#[error("fs::read_dir::file_type: {0}")] #[error("fs::read_dir::file_type: {0}")]
FileType(io::Error), FileType(io::Error),
/// There is an unexpected file in the directory /// There is an unexpected file in the directory.
#[error("unexpected file")] #[error("unexpected file")]
UnexpectedFile, UnexpectedFile,
/// There is an unexpected folder in the directory /// There is an unexpected folder in the directory.
#[error("unexpected folder")] #[error("unexpected folder")]
UnexpectedFolder, 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")] #[error("unexpected non-file")]
UnexpectedNonFile, UnexpectedNonFile,
/// There are multiple media files in the directory /// There are multiple media files in the directory.
#[error("duplicate media file")] #[error("duplicate media file")]
DuplicateMediaFile, DuplicateMediaFile,
/// There are multiple poster files in the directory /// There are multiple poster files in the directory.
#[error("duplicate poster file")] #[error("duplicate poster file")]
DuplicatePosterFile, DuplicatePosterFile,
/// The directory contains incomplete flix media /// The directory contains incomplete flix media.
#[error("incomplete")] #[error("incomplete")]
Incomplete, Incomplete,
/// Some data is inconsistent with the folder structure /// Some data is inconsistent with the folder structure.
#[error("inconsistent")] #[error("inconsistent")]
Inconsistent, Inconsistent,
} }
+7 -4
View File
@@ -1,18 +1,21 @@
//! Filesystem scan result items.
use std::path::PathBuf; use std::path::PathBuf;
use crate::Error; use crate::Error;
/// An item returned by scanner streams /// An item returned by scanner streams.
#[derive(Debug)] #[derive(Debug)]
#[expect(clippy::exhaustive_structs, reason = "unlikely to change")]
pub struct Item<T> { pub struct Item<T> {
/// The path of the item /// The path of the item.
pub path: PathBuf, pub path: PathBuf,
/// The event relating to the item /// The event relating to the item.
pub event: Result<T, Error>, pub event: Result<T, Error>,
} }
impl<T> Item<T> { impl<T> Item<T> {
/// Helper function for mapping the inner event [Result] /// Helper function for mapping the inner event [Result].
#[inline] #[inline]
pub fn map<U, F: FnOnce(T) -> U>(self, op: F) -> Item<U> { pub fn map<U, F: FnOnce(T) -> U>(self, op: F) -> Item<U> {
Item { 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))] #![cfg_attr(docsrs, feature(doc_cfg))]
+4
View File
@@ -1,3 +1,6 @@
//! Helper macros.
/// Defines media file extensions.
macro_rules! is_media_extension { macro_rules! is_media_extension {
() => { () => {
Some("mp4" | "mkv") Some("mp4" | "mkv")
@@ -5,6 +8,7 @@ macro_rules! is_media_extension {
} }
pub(super) use is_media_extension; pub(super) use is_media_extension;
/// Defines image file expensions.
macro_rules! is_image_extension { macro_rules! is_image_extension {
() => { () => {
Some("png" | "jpg") 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 core::pin::Pin;
use std::ffi::OsStr; use std::ffi::OsStr;
@@ -17,24 +17,27 @@ use crate::scanner::{
CollectionScan, EpisodeScan, MediaRef, MovieScan, SeasonScan, ShowScan, generic, movie, show, CollectionScan, EpisodeScan, MediaRef, MovieScan, SeasonScan, ShowScan, generic, movie, show,
}; };
/// A collection item /// A collection item.
pub type Item = crate::Item<Scanner>; pub type Item = crate::Item<Scanner>;
/// The scanner for collections /// The scanner for collections.
#[derive(Debug)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner { pub enum Scanner {
/// A scanned collection /// A scanned collection.
Collection(CollectionScan), Collection(CollectionScan),
/// A scanned movie /// A scanned movie.
Movie(MovieScan), Movie(MovieScan),
/// A scanned show /// A scanned show.
Show(ShowScan), Show(ShowScan),
/// A scanned episode /// A scanned episode.
Season(SeasonScan), Season(SeasonScan),
/// A scanned episode /// A scanned episode.
Episode(EpisodeScan), Episode(EpisodeScan),
} }
impl From<movie::Scanner> for Scanner { impl From<movie::Scanner> for Scanner {
#[inline]
fn from(value: movie::Scanner) -> Self { fn from(value: movie::Scanner) -> Self {
match value { match value {
movie::Scanner::Movie(m) => Self::Movie(m), movie::Scanner::Movie(m) => Self::Movie(m),
@@ -43,6 +46,7 @@ impl From<movie::Scanner> for Scanner {
} }
impl From<show::Scanner> for Scanner { impl From<show::Scanner> for Scanner {
#[inline]
fn from(value: show::Scanner) -> Self { fn from(value: show::Scanner) -> Self {
match value { match value {
show::Scanner::Show(s) => Self::Show(s), show::Scanner::Show(s) => Self::Show(s),
@@ -53,6 +57,7 @@ impl From<show::Scanner> for Scanner {
} }
impl From<generic::Scanner> for Scanner { impl From<generic::Scanner> for Scanner {
#[inline]
fn from(value: generic::Scanner) -> Self { fn from(value: generic::Scanner) -> Self {
match value { match value {
generic::Scanner::Collection(c) => Self::Collection(c), generic::Scanner::Collection(c) => Self::Collection(c),
@@ -65,7 +70,12 @@ impl From<generic::Scanner> for Scanner {
} }
impl 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( pub fn scan_collection(
path: &Path, path: &Path,
parent_ref: Option<MediaRef<CollectionId>>, parent_ref: Option<MediaRef<CollectionId>>,
@@ -151,7 +161,7 @@ impl Scanner {
for await event in for await event in
generic::Scanner::scan_detect_folder(&subdir, Some(id_ref.clone())) 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::ffi::OsStr;
use std::path::Path; use std::path::Path;
@@ -15,17 +15,23 @@ use crate::Error;
use crate::macros::{is_image_extension, is_media_extension}; use crate::macros::{is_image_extension, is_media_extension};
use crate::scanner::{EpisodeScan, MediaRef}; use crate::scanner::{EpisodeScan, MediaRef};
/// An episode item /// An episode item.
pub type Item = crate::Item<Scanner>; 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 { pub enum Scanner {
/// A scanned episode /// A scanned episode.
Episode(EpisodeScan), Episode(EpisodeScan),
} }
impl Scanner { 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( pub fn scan_episode(
path: &Path, path: &Path,
show_ref: MediaRef<ShowId>, show_ref: MediaRef<ShowId>,
@@ -62,6 +68,7 @@ impl Scanner {
continue; continue;
} }
}; };
#[expect(clippy::filetype_is_file, reason = "we only want regular files")]
if !filetype.is_file() { if !filetype.is_file() {
yield Item { yield Item {
path, path,
+33 -18
View File
@@ -19,28 +19,32 @@ use crate::scanner::{
CollectionScan, EpisodeScan, MediaRef, MovieScan, SeasonScan, ShowScan, collection, movie, show, CollectionScan, EpisodeScan, MediaRef, MovieScan, SeasonScan, ShowScan, collection, movie, show,
}; };
/// Regex for detecting a media folder.
static MEDIA_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new(); static MEDIA_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
/// Regex for detecting a season folder.
static SEASON_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new(); static SEASON_FOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
/// A collection item /// A collection item.
pub type Item = crate::Item<Scanner>; pub type Item = crate::Item<Scanner>;
/// The scanner for collections /// The scanner for collections.
#[derive(Debug)] #[derive(Debug)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner { pub enum Scanner {
/// A scanned collection /// A scanned collection.
Collection(CollectionScan), Collection(CollectionScan),
/// A scanned movie /// A scanned movie.
Movie(MovieScan), Movie(MovieScan),
/// A scanned show /// A scanned show.
Show(ShowScan), Show(ShowScan),
/// A scanned episode /// A scanned episode.
Season(SeasonScan), Season(SeasonScan),
/// A scanned episode /// A scanned episode.
Episode(EpisodeScan), Episode(EpisodeScan),
} }
impl From<collection::Scanner> for Scanner { impl From<collection::Scanner> for Scanner {
#[inline]
fn from(value: collection::Scanner) -> Self { fn from(value: collection::Scanner) -> Self {
match value { match value {
collection::Scanner::Collection(c) => Self::Collection(c), collection::Scanner::Collection(c) => Self::Collection(c),
@@ -53,6 +57,7 @@ impl From<collection::Scanner> for Scanner {
} }
impl From<movie::Scanner> for Scanner { impl From<movie::Scanner> for Scanner {
#[inline]
fn from(value: movie::Scanner) -> Self { fn from(value: movie::Scanner) -> Self {
match value { match value {
movie::Scanner::Movie(m) => Self::Movie(m), movie::Scanner::Movie(m) => Self::Movie(m),
@@ -61,6 +66,7 @@ impl From<movie::Scanner> for Scanner {
} }
impl From<show::Scanner> for Scanner { impl From<show::Scanner> for Scanner {
#[inline]
fn from(value: show::Scanner) -> Self { fn from(value: show::Scanner) -> Self {
match value { match value {
show::Scanner::Show(s) => Self::Show(s), show::Scanner::Show(s) => Self::Show(s),
@@ -71,13 +77,13 @@ impl From<show::Scanner> for Scanner {
} }
impl Scanner { impl Scanner {
/// Helper function for stripping allowed numerical prefixes for sorting ("01 - ") /// Helper function for stripping allowed numerical prefixes for sorting ("01 - ").
fn strip_numeric_prefix(original: &str) -> &str { #[inline]
let mut s = original; fn strip_numeric_prefix(string: &str) -> &str {
while let Some('0'..='9') = s.chars().next() { string
s = &s[1..] .trim_start_matches(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'])
} .strip_prefix(" - ")
s.strip_prefix(" - ").unwrap_or(original) .unwrap_or(string)
} }
/// Detect the type of a folder and call the correct scanner. Use /// Detect the type of a folder and call the correct scanner. Use
@@ -85,6 +91,13 @@ impl Scanner {
/// - Collections /// - Collections
/// - Movies /// - Movies
/// - Shows /// - 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( pub fn scan_detect_folder(
path: &Path, path: &Path,
parent: Option<MediaRef<CollectionId>>, parent: Option<MediaRef<CollectionId>>,
@@ -95,12 +108,14 @@ impl Scanner {
Show, Show,
} }
#[expect(clippy::panic, reason = "static regex string")]
let media_folder_re = MEDIA_FOLDER_REGEX.get_or_init(|| { let media_folder_re = MEDIA_FOLDER_REGEX.get_or_init(|| {
Regex::new(r"^[[[:alnum:]]' -]+ \([[:digit:]]+\)( \[[[:digit:]]+\])?$") Regex::new(r"^[[[:alnum:]]' -]+ \([[:digit:]]+\)( \[[[:digit:]]+\])?$")
.unwrap_or_else(|err| panic!("regex is invalid: {err}")) .unwrap_or_else(|err| panic!("regex is invalid: {err}"))
}); });
#[expect(clippy::panic, reason = "static regex string")]
let season_folder_re = SEASON_FOLDER_REGEX.get_or_init(|| { 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!({ stream!({
@@ -204,7 +219,7 @@ impl Scanner {
}; };
for await event in collection::Scanner::scan_collection(path, parent, id) { for await event in collection::Scanner::scan_collection(path, parent, id) {
yield event.map(|e| e.into()); yield event.map(Into::into);
} }
} }
MediaType::Movie => { MediaType::Movie => {
@@ -214,7 +229,7 @@ impl Scanner {
}; };
for await event in movie::Scanner::scan_movie(path, parent, id) { for await event in movie::Scanner::scan_movie(path, parent, id) {
yield event.map(|e| e.into()); yield event.map(Into::into);
} }
} }
MediaType::Show => { MediaType::Show => {
@@ -224,7 +239,7 @@ impl Scanner {
}; };
for await event in show::Scanner::scan_show(path, parent, id) { 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 //! The library scanner will scan an entire directory using the generic
//! scanner //! scanner.
use std::ffi::OsStr; use std::ffi::OsStr;
use std::path::Path; use std::path::Path;
@@ -12,14 +12,20 @@ use tokio_stream::wrappers::ReadDirStream;
use crate::Error; use crate::Error;
use crate::scanner::generic; use crate::scanner::generic;
/// A library item /// A library item.
pub type Item = crate::Item<generic::Scanner>; 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 {} pub enum Scanner {}
impl 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> { pub fn scan_library(path: &Path) -> impl Stream<Item = Item> {
stream!({ stream!({
let dirs = match fs::read_dir(path).await { let dirs = match fs::read_dir(path).await {
@@ -58,7 +64,7 @@ impl Scanner {
match path.extension().and_then(OsStr::to_str) { match path.extension().and_then(OsStr::to_str) {
Some(_) | None => { Some(_) | None => {
yield Item { yield Item {
path: path.to_owned(), path: path.clone(),
event: Err(Error::UnexpectedFile), 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. //! automatically detect and use the appropriate scanner.
use flix_model::id::{CollectionId, MovieId, ShowId}; use flix_model::id::{CollectionId, MovieId, ShowId};
@@ -18,82 +18,118 @@ pub mod episode;
pub mod season; pub mod season;
pub mod show; 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)] #[derive(Debug, Clone)]
pub enum MediaRef<ID> { pub enum MediaRef<ID> {
/// An explicit ID /// An explicit ID.
Id(ID), Id(ID),
/// A filesystem slug /// A filesystem slug.
Slug(String), Slug(String),
} }
impl<ID> MediaRef<ID> { impl<ID> MediaRef<ID> {
/// Get the slug if it exists /// Get the slug if it exists.
#[inline]
pub fn into_slug(self) -> Option<String> { pub fn into_slug(self) -> Option<String> {
match self { match self {
MediaRef::Id(_) => None, Self::Id(_) => None,
MediaRef::Slug(slug) => Some(slug), Self::Slug(slug) => Some(slug),
} }
} }
} }
/// A scanned collection /// A scanned collection.
#[derive(Debug)] #[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "it will be a breaking change when more information is scanned"
)]
pub struct CollectionScan { 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>>, pub parent_ref: Option<MediaRef<CollectionId>>,
/// The ID of the collection /// The ID of the collection.
pub id_ref: MediaRef<CollectionId>, 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>, pub poster_file_name: Option<String>,
} }
/// A scanned movie /// A scanned movie.
#[derive(Debug)] #[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "it will be a breaking change when more information is scanned"
)]
pub struct MovieScan { 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>>, pub parent_ref: Option<MediaRef<CollectionId>>,
/// The ID of the movie /// The ID of the movie.
pub id_ref: MediaRef<MovieId>, pub id_ref: MediaRef<MovieId>,
/// The file name of the media file /// The file name of the media file.
pub media_file_name: String, 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>, pub poster_file_name: Option<String>,
} }
/// A scanned show /// A scanned show.
#[derive(Debug)] #[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "it will be a breaking change when more information is scanned"
)]
pub struct ShowScan { 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>>, pub parent_ref: Option<MediaRef<CollectionId>>,
/// The ID of the show /// The ID of the show.
pub id_ref: MediaRef<ShowId>, 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>, pub poster_file_name: Option<String>,
} }
/// A scanned season /// A scanned season.
#[derive(Debug)] #[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "it will be a breaking change when more information is scanned"
)]
pub struct SeasonScan { 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>, pub show_ref: MediaRef<ShowId>,
/// The season this episode belongs to /// The season this episode belongs to.
pub season: SeasonNumber, pub season: SeasonNumber,
/// The file name of the poster file /// The file name of the poster file.
pub poster_file_name: Option<String>, pub poster_file_name: Option<String>,
} }
/// A scanned episode /// A scanned episode.
#[derive(Debug)] #[derive(Debug)]
#[expect(
clippy::exhaustive_structs,
reason = "it will be a breaking change when more information is scanned"
)]
pub struct EpisodeScan { 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>, pub show_ref: MediaRef<ShowId>,
/// The season this episode belongs to /// The season this episode belongs to.
pub season: SeasonNumber, pub season: SeasonNumber,
/// The number(s) of this episode /// The number(s) of this episode.
pub episode: EpisodeNumbers, pub episode: EpisodeNumbers,
/// The file name of the media file /// The file name of the media file.
pub media_file_name: String, 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>, 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::ffi::OsStr;
use std::path::Path; use std::path::Path;
@@ -14,17 +14,23 @@ use crate::Error;
use crate::macros::{is_image_extension, is_media_extension}; use crate::macros::{is_image_extension, is_media_extension};
use crate::scanner::{MediaRef, MovieScan}; use crate::scanner::{MediaRef, MovieScan};
/// An movie item /// An movie item.
pub type Item = crate::Item<Scanner>; 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 { pub enum Scanner {
/// A scanned movie /// A scanned movie.
Movie(MovieScan), Movie(MovieScan),
} }
impl Scanner { 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( pub fn scan_movie(
path: &Path, path: &Path,
parent_ref: Option<MediaRef<CollectionId>>, parent_ref: Option<MediaRef<CollectionId>>,
@@ -60,6 +66,7 @@ impl Scanner {
continue; continue;
} }
}; };
#[expect(clippy::filetype_is_file, reason = "we only want regular files")]
if !filetype.is_file() { if !filetype.is_file() {
yield Item { yield Item {
path, 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::ffi::OsStr;
use std::path::Path; use std::path::Path;
use flix_model::id::ShowId; use flix_model::id::ShowId;
use flix_model::numbers::{EpisodeNumber, EpisodeNumbers, SeasonNumber}; use flix_model::numbers::{EpisodeNumbers, SeasonNumber};
use async_stream::stream; use async_stream::stream;
use tokio::fs; use tokio::fs;
@@ -15,18 +15,21 @@ use crate::Error;
use crate::macros::is_image_extension; use crate::macros::is_image_extension;
use crate::scanner::{EpisodeScan, MediaRef, SeasonScan, episode}; use crate::scanner::{EpisodeScan, MediaRef, SeasonScan, episode};
/// A season item /// A season item.
pub type Item = crate::Item<Scanner>; pub type Item = crate::Item<Scanner>;
/// The scanner for seasons /// The scanner for seasons.
#[derive(Debug)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner { pub enum Scanner {
/// A scanned season /// A scanned season.
Season(SeasonScan), Season(SeasonScan),
/// A scanned episode /// A scanned episode.
Episode(EpisodeScan), Episode(EpisodeScan),
} }
impl From<episode::Scanner> for Scanner { impl From<episode::Scanner> for Scanner {
#[inline]
fn from(value: episode::Scanner) -> Self { fn from(value: episode::Scanner) -> Self {
match value { match value {
episode::Scanner::Episode(e) => Self::Episode(e), episode::Scanner::Episode(e) => Self::Episode(e),
@@ -35,7 +38,11 @@ impl From<episode::Scanner> for Scanner {
} }
impl 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( pub fn scan_season(
path: &Path, path: &Path,
show_ref: MediaRef<ShowId>, show_ref: MediaRef<ShowId>,
@@ -158,7 +165,7 @@ impl Scanner {
let Ok(episode_numbers) = e_str let Ok(episode_numbers) = e_str
.split('E') .split('E')
.map(|s| s.parse::<EpisodeNumber>()) .map(str::parse)
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
else { else {
yield Item { yield Item {
@@ -181,7 +188,7 @@ impl Scanner {
season_number, season_number,
episode_numbers, 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::ffi::OsStr;
use std::path::Path; use std::path::Path;
@@ -15,20 +15,23 @@ use crate::Error;
use crate::macros::is_image_extension; use crate::macros::is_image_extension;
use crate::scanner::{EpisodeScan, MediaRef, SeasonScan, ShowScan, season}; use crate::scanner::{EpisodeScan, MediaRef, SeasonScan, ShowScan, season};
/// A show item /// A show item.
pub type Item = crate::Item<Scanner>; pub type Item = crate::Item<Scanner>;
/// The scanner for shows /// The scanner for shows.
#[derive(Debug)]
#[expect(clippy::exhaustive_enums, reason = "unlikely to change")]
pub enum Scanner { pub enum Scanner {
/// A scanned show /// A scanned show.
Show(ShowScan), Show(ShowScan),
/// A scanned season /// A scanned season.
Season(SeasonScan), Season(SeasonScan),
/// A scanned episode /// A scanned episode.
Episode(EpisodeScan), Episode(EpisodeScan),
} }
impl From<season::Scanner> for Scanner { impl From<season::Scanner> for Scanner {
#[inline]
fn from(value: season::Scanner) -> Self { fn from(value: season::Scanner) -> Self {
match value { match value {
season::Scanner::Season(s) => Self::Season(s), season::Scanner::Season(s) => Self::Season(s),
@@ -38,7 +41,11 @@ impl From<season::Scanner> for Scanner {
} }
impl 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( pub fn scan_show(
path: &Path, path: &Path,
parent_ref: Option<MediaRef<CollectionId>>, parent_ref: Option<MediaRef<CollectionId>>,
@@ -143,7 +150,7 @@ impl Scanner {
for await event in for await event in
season::Scanner::scan_season(&season_dir, id_ref.clone(), season_number) 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] [package]
name = "flix-model" name = "flix-model"
version = "0.0.20" version = "0.1.0"
license-file.workspace = true
description = "Core types for flix data" description = "Core types for flix data"
repository = "https://github.com/QuantumShade/flix" repository = "https://git.skrundz.dev/quantumshade/flix"
categories = [] keywords = ["flix"]
categories = ["multimedia"]
include = ["/src"]
publish = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs] [package.metadata.docs.rs]
all-features = true 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; 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; pub use seamantic::model::id::SeaOrmRepr as RawId;
#[doc(hidden)] #[doc(hidden)]
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub enum Library {} pub enum Library {}
/// Type alias for a library ID /// Type alias for a library ID.
pub type LibraryId = Id<Library>; pub type LibraryId = Id<Library>;
#[doc(hidden)] #[doc(hidden)]
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub enum Collection {} pub enum Collection {}
/// Type alias for a collection ID /// Type alias for a collection ID.
pub type CollectionId = Id<Collection>; pub type CollectionId = Id<Collection>;
#[doc(hidden)] #[doc(hidden)]
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub enum Movie {} pub enum Movie {}
/// Type alias for a movie ID /// Type alias for a movie ID.
pub type MovieId = Id<Movie>; pub type MovieId = Id<Movie>;
#[doc(hidden)] #[doc(hidden)]
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub enum Show {} pub enum Show {}
/// Type alias for a show ID /// Type alias for a show ID.
pub type ShowId = Id<Show>; 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))] #![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::fmt;
use core::ops::RangeInclusive; use core::ops::RangeInclusive;
@@ -7,7 +7,7 @@ use std::collections::HashSet;
use seamantic::sea_orm; use seamantic::sea_orm;
/// Newtype for representing season numbers /// Newtype for representing season numbers.
#[derive( #[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, sea_orm::DeriveValueType, Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, sea_orm::DeriveValueType,
)] )]
@@ -17,13 +17,16 @@ use seamantic::sea_orm;
pub struct SeasonNumber(u32); pub struct SeasonNumber(u32);
impl SeasonNumber { impl SeasonNumber {
/// Create a `SeasonNumber` from an integer /// Create a [`SeasonNumber`] from an integer.
pub fn new(value: u32) -> Self { #[inline]
#[must_use]
pub const fn new(value: u32) -> Self {
Self(value) Self(value)
} }
} }
impl fmt::Display for SeasonNumber { impl fmt::Display for SeasonNumber {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f) self.0.fmt(f)
} }
@@ -32,12 +35,13 @@ impl fmt::Display for SeasonNumber {
impl FromStr for SeasonNumber { impl FromStr for SeasonNumber {
type Err = <u32 as FromStr>::Err; type Err = <u32 as FromStr>::Err;
#[inline]
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
u32::from_str(s).map(Self) u32::from_str(s).map(Self)
} }
} }
/// Newtype for representing episode numbers /// Newtype for representing episode numbers.
#[derive( #[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, sea_orm::DeriveValueType, Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, sea_orm::DeriveValueType,
)] )]
@@ -47,18 +51,23 @@ impl FromStr for SeasonNumber {
pub struct EpisodeNumber(u32); pub struct EpisodeNumber(u32);
impl EpisodeNumber { impl EpisodeNumber {
/// Create an `EpisodeNumber` from an integer /// Create an [`EpisodeNumber`] from an integer.
pub fn new(value: u32) -> Self { #[inline]
#[must_use]
pub const fn new(value: u32) -> Self {
Self(value) Self(value)
} }
/// Get the underlying value /// Get the underlying value.
pub fn into_inner(self) -> u32 { #[inline]
#[must_use]
pub const fn into_inner(self) -> u32 {
self.0 self.0
} }
} }
impl fmt::Display for EpisodeNumber { impl fmt::Display for EpisodeNumber {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f) self.0.fmt(f)
} }
@@ -67,33 +76,40 @@ impl fmt::Display for EpisodeNumber {
impl FromStr for EpisodeNumber { impl FromStr for EpisodeNumber {
type Err = <u32 as FromStr>::Err; type Err = <u32 as FromStr>::Err;
#[inline]
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
u32::from_str(s).map(Self) u32::from_str(s).map(Self)
} }
} }
/// Potential errors when building EpisodeNumbers /// Potential errors when building [`EpisodeNumbers`].
#[derive(Debug, thiserror::Error)] #[derive(Debug, Clone, Copy, thiserror::Error)]
pub enum Error { #[expect(clippy::exhaustive_enums, reason = "unlikely to add new variants")]
/// There are no episodes pub enum EpisodeNumbersError {
/// There are no episodes.
#[error("zero episodes")] #[error("zero episodes")]
Zero, Zero,
/// There are gaps in the episodes /// There are gaps in the episodes.
#[error("noncontiguous episodes")] #[error("noncontiguous episodes")]
Noncontiguous, Noncontiguous,
} }
/// A wrapper for handling single and multi-episode entries /// A wrapper for handling single and multi-episode entries.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[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>); pub struct EpisodeNumbers(RangeInclusive<EpisodeNumber>);
impl TryFrom<&[EpisodeNumber]> for EpisodeNumbers { impl TryFrom<&[EpisodeNumber]> for EpisodeNumbers {
type Error = Error; type Error = EpisodeNumbersError;
#[inline]
fn try_from(value: &[EpisodeNumber]) -> Result<Self, Self::Error> { fn try_from(value: &[EpisodeNumber]) -> Result<Self, Self::Error> {
match value { match value {
[] => Err(Error::Zero), [] => Err(EpisodeNumbersError::Zero),
[n] => Ok(Self(*n..=*n)), [n] => Ok(Self(*n..=*n)),
_ => { _ => {
// min and max will always exist // min and max will always exist
@@ -102,12 +118,12 @@ impl TryFrom<&[EpisodeNumber]> for EpisodeNumbers {
let len = value.len(); let len = value.len();
if usize::try_from(max.0.saturating_sub(min.0).saturating_add(1)) != Ok(len) { if usize::try_from(max.0.saturating_sub(min.0).saturating_add(1)) != Ok(len) {
return Err(Error::Noncontiguous); return Err(EpisodeNumbersError::Noncontiguous);
} }
let set: HashSet<_> = value.iter().copied().collect(); let set: HashSet<_> = value.iter().copied().collect();
if set.len() != len { if set.len() != len {
return Err(Error::Noncontiguous); return Err(EpisodeNumbersError::Noncontiguous);
} }
Ok(Self(min..=max)) Ok(Self(min..=max))
@@ -117,27 +133,45 @@ impl TryFrom<&[EpisodeNumber]> for EpisodeNumbers {
} }
impl 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. /// `count` should be zero for single episodes.
#[inline]
#[must_use]
pub fn new(start: EpisodeNumber, count: u8) -> Self { pub fn new(start: EpisodeNumber, count: u8) -> Self {
Self(start..=EpisodeNumber(start.0.saturating_add(count.into()))) Self(start..=EpisodeNumber(start.0.saturating_add(count.into())))
} }
/// Get the range of episodes /// Get the range of episodes.
pub fn as_range(&self) -> &RangeInclusive<EpisodeNumber> { #[inline]
#[must_use]
pub const fn as_range(&self) -> &RangeInclusive<EpisodeNumber> {
&self.0 &self.0
} }
/// Render this [EpisodeNumbers] as a range. If only one episode is /// Render this [`EpisodeNumbers`] as a range. If only one episode is
/// is present it renders as `01`, if multiple it renders as `01-02` /// is present it renders as `01`, if multiple it renders as `01-02`.
#[inline]
#[must_use]
pub fn range_string(&self) -> String { pub fn range_string(&self) -> String {
let start = self.0.start(); let start = self.0.start();
let end = self.0.end(); let end = self.0.end();
if start == end { if start == end {
format!("{:02}", start) format!("{start:02}")
} else { } 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 core::iter::Peekable;
use itertools::Itertools; use itertools::Itertools;
/// Return an iterator over the normalized words of a string.
/// - Alphanumeric
/// - Lowercase
/// - Replace `&` with `and`
/// - Collapse acronyms
///
/// # Panics /// # Panics
/// ///
/// Panics if `input` is not ASCII. /// Panics if `input` is not ASCII.
fn split_normalized_words(input: &str) -> impl Iterator<Item = String> { fn split_normalized_words(input: &str) -> impl Iterator<Item = String> {
if !input.is_ascii() { assert!(input.is_ascii(), "input is not ASCII: {input}");
panic!("Input is not ASCII: {input}");
}
input input
.split_ascii_whitespace() .split_ascii_whitespace()
.map(|s| { .map(|s| {
if s == "&" { if s == "&" {
return "and".to_string(); return "and".to_owned();
} }
let chars = s let chars = s
@@ -37,6 +41,7 @@ fn split_normalized_words(input: &str) -> impl Iterator<Item = String> {
.filter(|part: &String| !part.is_empty() && part != "-") .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>) { fn split_leading_article<I: Iterator<Item = String>>(iter: I) -> (Option<String>, Peekable<I>) {
let mut iter = iter.peekable(); let mut iter = iter.peekable();
match iter.peek().map(String::as_str) { 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
/// ///
/// Panics if `input` is not ASCII. /// Panics if `input` is not ASCII.
#[inline]
#[must_use]
pub fn make_sortable_title(title: &str) -> String { pub fn make_sortable_title(title: &str) -> String {
let words = split_normalized_words(title); let words = split_normalized_words(title);
let (article, words) = split_leading_article(words); 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 { if let Some(article) = article {
output.chain([", ".to_string(), article]).collect() output.chain([", ".to_owned(), article]).collect()
} else { } else {
output.collect() output.collect()
} }
@@ -81,11 +88,13 @@ pub fn make_sortable_title(title: &str) -> String {
/// # Panics /// # Panics
/// ///
/// Panics if `input` is not ASCII. /// Panics if `input` is not ASCII.
#[inline]
#[must_use]
pub fn make_fs_slug(title: &str) -> String { pub fn make_fs_slug(title: &str) -> String {
let words = split_normalized_words(title); let words = split_normalized_words(title);
let (_, words) = split_leading_article(words); 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 /// 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
/// ///
/// Panics if `input` is not ASCII. /// Panics if `input` is not ASCII.
#[inline]
#[must_use]
pub fn make_fs_slug_year(title: &str, year: i32) -> String { pub fn make_fs_slug_year(title: &str, year: i32) -> String {
let words = split_normalized_words(title); let words = split_normalized_words(title);
let (_, words) = split_leading_article(words); let (_, words) = split_leading_article(words);
Itertools::intersperse(words, " ".to_string()) Itertools::intersperse(words, " ".to_owned())
.chain([format!(" ({year})")]) .chain([format!(" ({year})")])
.collect() .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("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("Avatar The Last Airbender (2005)"), "avatar the last airbender (2005)");
/// assert_eq!(normalize_fs_name("Cloak & Dagger (2018)"), "cloak and dagger (2018)"); /// assert_eq!(normalize_fs_name("Cloak & Dagger (2018)"), "cloak and dagger (2018)");
#[inline]
#[must_use]
pub fn normalize_fs_name(input: &str) -> String { pub fn normalize_fs_name(input: &str) -> String {
let chars = input.split_ascii_whitespace().map(|s| { let chars = input.split_ascii_whitespace().map(|s| {
if s == "&" { if s == "&" {
return "and".to_string(); return "and".to_owned();
} }
let chars = s let chars = s
@@ -138,7 +151,7 @@ pub fn normalize_fs_name(input: &str) -> String {
chars.collect() chars.collect()
} }
}); });
Itertools::intersperse(chars, " ".to_string()).collect() Itertools::intersperse(chars, " ".to_owned()).collect()
} }
/// Convert a media title to a url compatible string /// Convert a media title to a url compatible string
@@ -153,11 +166,13 @@ pub fn normalize_fs_name(input: &str) -> String {
/// # Panics /// # Panics
/// ///
/// Panics if `input` is not ASCII. /// Panics if `input` is not ASCII.
#[inline]
#[must_use]
pub fn make_web_slug(title: &str) -> String { pub fn make_web_slug(title: &str) -> String {
let words = split_normalized_words(title); let words = split_normalized_words(title);
let (_, words) = split_leading_article(words); 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 /// 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
/// ///
/// Panics if `input` is not ASCII. /// Panics if `input` is not ASCII.
#[inline]
#[must_use]
pub fn make_web_slug_year(title: &str, year: i32) -> String { pub fn make_web_slug_year(title: &str, year: i32) -> String {
let words = split_normalized_words(title); let words = split_normalized_words(title);
let (_, words) = split_leading_article(words); let (_, words) = split_leading_article(words);
Itertools::intersperse(words, "-".to_string()) Itertools::intersperse(words, "-".to_owned())
.chain([format!("-{year}")]) .chain([format!("-{year}")])
.collect() .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] [package]
name = "flix-tmdb" name = "flix-tmdb"
version = "0.0.20" version = "0.1.0"
license-file.workspace = true
description = "Clients and models for fetching data from TMDB" description = "Clients and models for fetching data from TMDB"
repository = "https://github.com/QuantumShade/flix" repository = "https://git.skrundz.dev/quantumshade/flix"
categories = [] keywords = ["flix"]
categories = ["multimedia"]
include = ["/src"]
publish = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs] [package.metadata.docs.rs]
all-features = true all-features = true
@@ -32,6 +34,7 @@ sea-orm = { workspace = true, optional = true }
[dev-dependencies] [dev-dependencies]
serde_test = { workspace = true } serde_test = { workspace = true }
tempfile = { workspace = true }
[features] [features]
default = [] 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 std::sync::RwLock;
use crate::api::exec_request; use crate::api::exec_request;
@@ -10,26 +10,38 @@ use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request}; use super::{Error, make_request};
/// TMDB Collections API client /// TMDB Collections API client.
#[derive(Debug)]
pub struct Client { pub struct Client {
config: Rc<Config>, /// The client configuration.
cache: Rc<dyn Cache>, config: Arc<Config>,
policy: Rc<RwLock<CachePolicy>>, /// The request cache.
cache: Arc<dyn Cache>,
/// The cache policy to use for requests.
policy: Arc<RwLock<CachePolicy>>,
} }
impl Client { impl Client {
/// Create a new client with the given configuration /// Create a new client with the given configuration.
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self { #[inline]
pub fn new(
config: Arc<Config>,
cache: Arc<dyn Cache>,
policy: Arc<RwLock<CachePolicy>>,
) -> Self {
Self { Self {
config, config,
cache, cache,
policy, 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( pub async fn get_details(
&self, &self,
id: impl Into<CollectionId>, 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 std::sync::RwLock;
use flix_model::numbers::{EpisodeNumber, SeasonNumber}; use flix_model::numbers::{EpisodeNumber, SeasonNumber};
@@ -12,26 +12,38 @@ use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request}; use super::{Error, make_request};
/// TMDB Episodes API client /// TMDB Episodes API client.
#[derive(Debug)]
pub struct Client { pub struct Client {
config: Rc<Config>, /// The client configuration.
cache: Rc<dyn Cache>, config: Arc<Config>,
policy: Rc<RwLock<CachePolicy>>, /// The request cache.
cache: Arc<dyn Cache>,
/// The cache policy to use for requests.
policy: Arc<RwLock<CachePolicy>>,
} }
impl Client { impl Client {
/// Create a new client with the given configuration /// Create a new client with the given configuration.
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self { #[inline]
pub fn new(
config: Arc<Config>,
cache: Arc<dyn Cache>,
policy: Arc<RwLock<CachePolicy>>,
) -> Self {
Self { Self {
config, config,
cache, cache,
policy, 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( pub async fn get_details(
&self, &self,
id: impl Into<ShowId>, id: impl Into<ShowId>,
+49 -35
View File
@@ -1,6 +1,5 @@
//! TMDB API clients //! TMDB API clients.
use core::ops::Deref;
use core::time::Duration; use core::time::Duration;
use std::sync::RwLock; use std::sync::RwLock;
@@ -17,20 +16,26 @@ pub mod movies;
pub mod seasons; pub mod seasons;
pub mod shows; pub mod shows;
/// A generic error wrapping Url and Reqwest errors /// A generic error wrapping Url and Reqwest errors.
#[derive(Debug, thiserror::Error)] #[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 { pub enum Error {
/// Url error wrapper /// Url error wrapper.
#[error("url parse error: {0}")] #[error("url parse error: {0}")]
Url(#[from] url::ParseError), Url(#[from] url::ParseError),
/// Reqwest error wrapper /// Reqwest error wrapper.
#[error("reqwest error: {0}")] #[error("reqwest error: {0}")]
Reqwest(#[from] reqwest::Error), Reqwest(#[from] reqwest::Error),
/// Json error wrapper /// Json error wrapper.
#[error("json error: {0}")] #[error("json error: {0}")]
Json(#[from] serde_json::Error), 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> { fn make_request(config: &Config, path: &str, language: Option<&str>) -> Result<Request, Error> {
let url = config.base_url.join(path)?; 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, header::AUTHORIZATION,
format!("Bearer {}", config.bearer_token), 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); builder = builder.header(header::USER_AGENT, user_agent);
} }
if let Some(language) = language { if let Some(language) = language {
@@ -48,22 +53,22 @@ fn make_request(config: &Config, path: &str, language: Option<&str>) -> Result<R
Ok(builder.build()?) Ok(builder.build()?)
} }
/// Execute a [Request] and deserialize the response.
///
/// # Errors
/// See [Error] for failure modes.
async fn exec_request<T: DeserializeOwned>( async fn exec_request<T: DeserializeOwned>(
config: &Config, config: &Config,
cache: &dyn Cache, cache: &dyn Cache,
policy: &RwLock<CachePolicy>, policy: &RwLock<CachePolicy>,
request: Request, request: Request,
) -> Result<T, Error> { ) -> Result<T, Error> {
let (read_cache, write_cache) = if let Ok(guard) = policy.read() { let (read_cache, write_cache) = policy.read().map_or((None, None), |guard| match *guard {
match guard.deref() { CachePolicy::None => (None, None),
CachePolicy::None => (None, None), CachePolicy::Full => (Some(cache), Some(cache)),
CachePolicy::Full => (Some(cache), Some(cache)), CachePolicy::Read => (Some(cache), None),
CachePolicy::Read => (Some(cache), None), CachePolicy::Update => (None, Some(cache)),
CachePolicy::Update => (None, Some(cache)), });
}
} else {
(None, None)
};
let path = request.url().path().to_owned(); let path = request.url().path().to_owned();
@@ -73,24 +78,23 @@ async fn exec_request<T: DeserializeOwned>(
response = cache.get(&path); response = cache.get(&path);
} }
let needs_cache_write = response.is_none(); let needs_cache_write = response.is_none();
let response = match response { let response = if let Some(response) = response {
Some(response) => response, response
None => { } else {
config config
.limiter .limiter
.until_ready_with_jitter(Jitter::new( .until_ready_with_jitter(Jitter::new(
Duration::from_millis(0), Duration::from_millis(0),
Duration::from_millis(50), Duration::from_millis(50),
)) ))
.await; .await;
config config
.client .client
.execute(request) .execute(request)
.await? .await?
.error_for_status()? .error_for_status()?
.bytes() .bytes()
.await? .await?
}
}; };
// write to the cache if needed // write to the cache if needed
@@ -102,3 +106,13 @@ async fn exec_request<T: DeserializeOwned>(
Ok(serde_json::from_slice(&response)?) 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 std::sync::RwLock;
use crate::api::exec_request; use crate::api::exec_request;
@@ -10,26 +10,38 @@ use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request}; use super::{Error, make_request};
/// TMDB Movies API client /// TMDB Movies API client.
#[derive(Debug)]
pub struct Client { pub struct Client {
config: Rc<Config>, /// The client configuration.
cache: Rc<dyn Cache>, config: Arc<Config>,
policy: Rc<RwLock<CachePolicy>>, /// The request cache.
cache: Arc<dyn Cache>,
/// The cache policy to use for requests.
policy: Arc<RwLock<CachePolicy>>,
} }
impl Client { impl Client {
/// Create a new client with the given configuration /// Create a new client with the given configuration.
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self { #[inline]
pub fn new(
config: Arc<Config>,
cache: Arc<dyn Cache>,
policy: Arc<RwLock<CachePolicy>>,
) -> Self {
Self { Self {
config, config,
cache, cache,
policy, 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( pub async fn get_details(
&self, &self,
id: impl Into<MovieId>, 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 std::sync::RwLock;
use flix_model::numbers::SeasonNumber; use flix_model::numbers::SeasonNumber;
@@ -12,26 +12,38 @@ use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request}; use super::{Error, make_request};
/// TMDB Seasons API client /// TMDB Seasons API client.
#[derive(Debug)]
pub struct Client { pub struct Client {
config: Rc<Config>, /// The client configuration.
cache: Rc<dyn Cache>, config: Arc<Config>,
policy: Rc<RwLock<CachePolicy>>, /// The request cache.
cache: Arc<dyn Cache>,
/// The cache policy to use for requests.
policy: Arc<RwLock<CachePolicy>>,
} }
impl Client { impl Client {
/// Create a new client with the given configuration /// Create a new client with the given configuration.
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self { #[inline]
pub fn new(
config: Arc<Config>,
cache: Arc<dyn Cache>,
policy: Arc<RwLock<CachePolicy>>,
) -> Self {
Self { Self {
config, config,
cache, cache,
policy, 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( pub async fn get_details(
&self, &self,
id: impl Into<ShowId>, 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 std::sync::RwLock;
use crate::api::exec_request; use crate::api::exec_request;
@@ -10,26 +10,38 @@ use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request}; use super::{Error, make_request};
/// TMDB Shows API client /// TMDB Shows API client.
#[derive(Debug)]
pub struct Client { pub struct Client {
config: Rc<Config>, /// The client configuration.
cache: Rc<dyn Cache>, config: Arc<Config>,
policy: Rc<RwLock<CachePolicy>>, /// The request cache.
cache: Arc<dyn Cache>,
/// The cache policy to use for requests.
policy: Arc<RwLock<CachePolicy>>,
} }
impl Client { impl Client {
/// Create a new client with the given configuration /// Create a new client with the given configuration.
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self { #[inline]
pub fn new(
config: Arc<Config>,
cache: Arc<dyn Cache>,
policy: Arc<RwLock<CachePolicy>>,
) -> Self {
Self { Self {
config, config,
cache, cache,
policy, 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( pub async fn get_details(
&self, &self,
id: impl Into<ShowId>, id: impl Into<ShowId>,
+35 -22
View File
@@ -1,58 +1,72 @@
//! Caching related types.
use std::path::Path; use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use bytes::Bytes; 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 { pub enum CachePolicy {
/// Do not use a cache /// Do not use a cache.
None, None,
/// Use and update the cache /// Use and update the cache.
Full, Full,
/// Use the cache but don't update it /// Use the cache but don't update it.
Read, Read,
/// Ignore the cache but update it /// Ignore the cache but update it.
Update, Update,
} }
/// The trait representing a caching backend /// The trait representing a caching backend.
pub trait Cache { pub trait Cache: core::fmt::Debug + Send + Sync {
/// Get a cached value, or None /// Get a cached value, or None.
fn get(&self, query: &str) -> Option<Bytes>; 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); 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 { pub struct RedbCache {
/// The underlying database.
db: Database, db: Database,
} }
impl RedbCache { 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> { pub fn new(path: &Path) -> Result<Self, DatabaseError> {
Ok(Self { Ok(Self {
db: Database::create(path)?, db: Database::create(path)?,
}) })
} }
/// Helper function allowing for `.ok()?` /// Helper function allowing for `.ok()?`.
fn write(&self, timestamp: u64, query: &str, response: &Bytes) -> Option<()> { fn write(&self, timestamp: u64, query: &str, response: &Bytes) -> Option<()> {
let write_txn = self.db.begin_write().ok()?; let write_txn = self.db.begin_write().ok()?;
{ {
let mut table = write_txn.open_table(TABLE).ok()?; let mut table = write_txn.open_table(TABLE).ok()?;
table drop(
.insert(query, (timestamp, response.iter().as_slice())) table
.ok()?; .insert(query, (timestamp, response.iter().as_slice()))
.ok()?,
);
} }
write_txn.commit().ok() write_txn.commit().ok()
} }
} }
impl Cache for RedbCache { impl Cache for RedbCache {
#[inline]
fn get(&self, query: &str) -> Option<Bytes> { fn get(&self, query: &str) -> Option<Bytes> {
let read_txn = self.db.begin_read().ok()?; let read_txn = self.db.begin_read().ok()?;
let table = read_txn.open_table(TABLE).ok()?; let table = read_txn.open_table(TABLE).ok()?;
@@ -62,8 +76,7 @@ impl Cache for RedbCache {
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|d| d.as_secs()) .map_or(0, |d| d.as_secs());
.unwrap_or(0);
if now.saturating_sub(timestamp) >= 60 * 60 * 24 * 30 * 6 { if now.saturating_sub(timestamp) >= 60 * 60 * 24 * 30 * 6 {
None None
@@ -72,12 +85,12 @@ impl Cache for RedbCache {
} }
} }
#[inline]
fn set(&self, query: &str, response: &Bytes) { fn set(&self, query: &str, response: &Bytes) {
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|d| d.as_secs()) .map_or(0, |d| d.as_secs());
.unwrap_or(0);
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 std::sync::RwLock;
use crate::{Cache, CachePolicy, Config, api}; 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 { pub struct Client {
/// The collection API client.
collections: api::collections::Client, collections: api::collections::Client,
/// The movie API client.
movies: api::movies::Client, movies: api::movies::Client,
/// The show API client.
shows: api::shows::Client, shows: api::shows::Client,
/// The season API client.
seasons: api::seasons::Client, seasons: api::seasons::Client,
/// The episode API client.
episodes: api::episodes::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 { impl Client {
/// Create a new client with the given configuration /// Create a new client with the given configuration.
pub fn new(config: Config, cache: Rc<dyn Cache>, cache_policy: CachePolicy) -> Self { #[inline]
let config = Rc::new(config); pub fn new(config: Config, cache: Arc<dyn Cache>, cache_policy: CachePolicy) -> Self {
let cache_policy = Rc::new(RwLock::new(cache_policy)); let config = Arc::new(config);
let cache_policy = Arc::new(RwLock::new(cache_policy));
Self { Self {
collections: api::collections::Client::new( collections: api::collections::Client::new(
config.clone(), Arc::clone(&config),
cache.clone(), Arc::clone(&cache),
cache_policy.clone(), Arc::clone(&cache_policy),
), ),
movies: api::movies::Client::new(config.clone(), cache.clone(), cache_policy.clone()), movies: api::movies::Client::new(
shows: api::shows::Client::new(config.clone(), cache.clone(), cache_policy.clone()), Arc::clone(&config),
seasons: api::seasons::Client::new(config.clone(), cache.clone(), cache_policy.clone()), Arc::clone(&cache),
episodes: api::episodes::Client::new( Arc::clone(&cache_policy),
config.clone(),
cache.clone(),
cache_policy.clone(),
), ),
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, cache_policy,
} }
} }
/// Modify the [CachePolicy] /// Modify the [`CachePolicy`].
#[inline]
pub fn set_cache_policy(&self, new_policy: CachePolicy) { pub fn set_cache_policy(&self, new_policy: CachePolicy) {
match self.cache_policy.write() { match self.cache_policy.write() {
Ok(mut policy) => *policy = new_policy, Ok(mut policy) => *policy = new_policy,
@@ -48,31 +67,39 @@ impl Client {
} }
} }
} }
}
impl Client { /// Access the Collections API.
/// Access the Collections API #[inline]
pub fn collections(&self) -> &api::collections::Client { #[must_use]
pub const fn collections(&self) -> &api::collections::Client {
&self.collections &self.collections
} }
/// Access the Movies API /// Access the Movies API.
pub fn movies(&self) -> &api::movies::Client { #[inline]
#[must_use]
pub const fn movies(&self) -> &api::movies::Client {
&self.movies &self.movies
} }
/// Access the Shows API /// Access the Shows API.
pub fn shows(&self) -> &api::shows::Client { #[inline]
#[must_use]
pub const fn shows(&self) -> &api::shows::Client {
&self.shows &self.shows
} }
/// Access the Seasons API /// Access the Seasons API.
pub fn seasons(&self) -> &api::seasons::Client { #[inline]
#[must_use]
pub const fn seasons(&self) -> &api::seasons::Client {
&self.seasons &self.seasons
} }
/// Access the Episodes API /// Access the Episodes API.
pub fn episodes(&self) -> &api::episodes::Client { #[inline]
#[must_use]
pub const fn episodes(&self) -> &api::episodes::Client {
&self.episodes &self.episodes
} }
} }
+14 -8
View File
@@ -1,3 +1,5 @@
//! Client configuration.
use governor::clock::MonotonicClock; use governor::clock::MonotonicClock;
use governor::state::{InMemoryState, NotKeyed}; use governor::state::{InMemoryState, NotKeyed};
use governor::{Quota, RateLimiter}; use governor::{Quota, RateLimiter};
@@ -5,27 +7,31 @@ use nonzero_ext::nonzero;
use url::Url; use url::Url;
use url_macro::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 { pub struct Config {
/// The base URL of the API /// The base URL of the API.
pub base_url: Url, 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, 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>, 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, 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>, pub user_agent: Option<String>,
} }
impl Config { 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 { pub fn new(bearer_token: String) -> Self {
Self { Self {
base_url: url!("https://api.themoviedb.org"), base_url: url!("https://api.themoviedb.org"),
client: reqwest::Client::new(), client: reqwest::Client::new(),
limiter: RateLimiter::direct(Quota::per_second(nonzero!(30u32))), limiter: RateLimiter::direct(Quota::per_second(nonzero!(30_u32))),
bearer_token, bearer_token,
user_agent: None, 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))] #![cfg_attr(docsrs, feature(doc_cfg))]
extern crate alloc;
pub mod api; pub mod api;
pub mod model; pub mod model;
@@ -13,3 +15,20 @@ pub use client::Client;
mod config; mod config;
pub use config::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}; use super::id::{CollectionId, MovieId};
/// A deserialized Collection from the TMDB API /// A deserialized Collection from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)] #[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Collection { pub struct Collection {
/// The collection's TMDB ID /// The collection's TMDB ID.
pub id: CollectionId, pub id: CollectionId,
/// The collection's title /// The collection's title.
#[serde(rename = "name")] #[serde(rename = "name")]
pub title: String, pub title: String,
/// The collection's overview /// The collection's overview.
pub overview: String, 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")] #[serde(rename = "parts")]
pub movies: Vec<Item>, 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)] #[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Item { pub struct Item {
/// The movie's TMDB ID /// The movie's TMDB ID.
pub id: MovieId, pub id: MovieId,
/// The movie's title /// The movie's title.
pub title: String, 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 core::time::Duration;
use flix_model::numbers::EpisodeNumber; use flix_model::numbers::EpisodeNumber;
@@ -7,22 +9,44 @@ use url::Url;
use super::{duration_from_minutes, still_url_from_path}; use super::{duration_from_minutes, still_url_from_path};
/// A deserialized Episode from the TMDB API /// A deserialized Episode from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)] #[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Episode { pub struct Episode {
/// The episode's number /// The episode's number.
pub episode_number: EpisodeNumber, pub episode_number: EpisodeNumber,
/// The episode's title /// The episode's title.
#[serde(rename = "name")] #[serde(rename = "name")]
pub title: String, pub title: String,
/// The episode's overview /// The episode's overview.
pub overview: String, pub overview: String,
/// The episode's air date /// The episode's air date.
pub air_date: NaiveDate, pub air_date: NaiveDate,
/// The episode's runtime /// The episode's runtime.
#[serde(deserialize_with = "duration_from_minutes")] #[serde(deserialize_with = "duration_from_minutes")]
pub runtime: Duration, pub runtime: Duration,
/// The episode's still path /// The episode's still path.
#[serde(deserialize_with = "still_url_from_path")] #[serde(deserialize_with = "still_url_from_path")]
pub still_path: Option<Url>, 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::cmp::Ordering;
use core::fmt; use core::fmt;
@@ -10,21 +12,25 @@ use sea_orm::sea_query::{ArrayType, Nullable, ValueType, ValueTypeErr};
#[cfg(feature = "sea-orm")] #[cfg(feature = "sea-orm")]
use sea_orm::{ColIdx, ColumnType, DbErr, QueryResult, TryFromU64, TryGetError, TryGetable, Value}; 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; pub type TmdbRepr = u32;
/// An opaque type representing a TMDB ID /// An opaque type representing a TMDB ID.
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
#[serde(transparent)] #[serde(transparent)]
#[repr(transparent)] #[repr(transparent)]
pub struct Id<T> { pub struct Id<T> {
/// Internal ID representation.
id: TmdbRepr, id: TmdbRepr,
/// Marker to indicate an invariant T.
#[serde(skip_serializing, default)] #[serde(skip_serializing, default)]
_phantom: PhantomData<T>, _phantom: PhantomData<fn(T) -> T>,
} }
// Manual implementation since `T: Clone` is not required // Manual implementation since `T: Clone` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> Clone for Id<T> { impl<T> Clone for Id<T> {
#[inline]
fn clone(&self) -> Self { fn clone(&self) -> Self {
*self *self
} }
@@ -34,31 +40,40 @@ impl<T> Clone for Id<T> {
impl<T> Copy for Id<T> {} impl<T> Copy for Id<T> {}
// Manual implementation since `T: PartialEq` is not required // Manual implementation since `T: PartialEq` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> PartialEq for Id<T> { impl<T> PartialEq for Id<T> {
#[inline]
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
self.id == other.id self.id == other.id
} }
} }
// Manual implementation since `T: Eq` is not required // Manual implementation since `T: Eq` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> Eq for Id<T> {} impl<T> Eq for Id<T> {}
// Manual implementation since `T: PartialOrd` is not required // Manual implementation since `T: PartialOrd` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> PartialOrd for Id<T> { impl<T> PartialOrd for Id<T> {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> { fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other)) Some(self.cmp(other))
} }
} }
// Manual implementation since `T: Ord` is not required // Manual implementation since `T: Ord` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> Ord for Id<T> { impl<T> Ord for Id<T> {
#[inline]
fn cmp(&self, other: &Self) -> Ordering { fn cmp(&self, other: &Self) -> Ordering {
self.id.cmp(&other.id) self.id.cmp(&other.id)
} }
} }
// Manual implementation since `T: Hash` is not required // Manual implementation since `T: Hash` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> Hash for Id<T> { impl<T> Hash for Id<T> {
#[inline]
fn hash<H: Hasher>(&self, state: &mut H) { fn hash<H: Hasher>(&self, state: &mut H) {
self.id.hash(state); self.id.hash(state);
} }
@@ -66,6 +81,8 @@ impl<T> Hash for Id<T> {
impl<T> Id<T> { impl<T> Id<T> {
/// Allows the conversion from a raw value to [Id], though the use is discouraged. /// Allows the conversion from a raw value to [Id], though the use is discouraged.
#[inline]
#[must_use]
pub fn from_raw(raw: TmdbRepr) -> Self { pub fn from_raw(raw: TmdbRepr) -> Self {
Self { Self {
id: raw, id: raw,
@@ -74,13 +91,16 @@ impl<T> Id<T> {
} }
/// Allows extracting the raw value, though the use is discouraged. /// 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 self.id
} }
} }
impl<T> fmt::Debug for Id<T> { 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") f.debug_struct("Id")
.field("T", &core::any::type_name::<T>()) .field("T", &core::any::type_name::<T>())
.field("id", &self.id) .field("id", &self.id)
@@ -89,7 +109,9 @@ impl<T> fmt::Debug for Id<T> {
} }
#[cfg(feature = "sea-orm")] #[cfg(feature = "sea-orm")]
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> ValueType for Id<T> { impl<T> ValueType for Id<T> {
#[inline]
fn try_from(v: Value) -> Result<Self, ValueTypeErr> { fn try_from(v: Value) -> Result<Self, ValueTypeErr> {
<TmdbRepr as ValueType>::try_from(v).map(|id| Self { <TmdbRepr as ValueType>::try_from(v).map(|id| Self {
id, id,
@@ -97,14 +119,17 @@ impl<T> ValueType for Id<T> {
}) })
} }
#[inline]
fn type_name() -> String { fn type_name() -> String {
format!("Id<{}>", &core::any::type_name::<T>()) format!("Id<{}>", core::any::type_name::<T>())
} }
#[inline]
fn array_type() -> ArrayType { fn array_type() -> ArrayType {
TmdbRepr::array_type() TmdbRepr::array_type()
} }
#[inline]
fn column_type() -> ColumnType { fn column_type() -> ColumnType {
TmdbRepr::column_type() TmdbRepr::column_type()
} }
@@ -112,13 +137,16 @@ impl<T> ValueType for Id<T> {
#[cfg(feature = "sea-orm")] #[cfg(feature = "sea-orm")]
impl<T> From<Id<T>> for Value { impl<T> From<Id<T>> for Value {
#[inline]
fn from(value: Id<T>) -> Self { fn from(value: Id<T>) -> Self {
value.id.into() value.id.into()
} }
} }
#[cfg(feature = "sea-orm")] #[cfg(feature = "sea-orm")]
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> TryGetable for Id<T> { impl<T> TryGetable for Id<T> {
#[inline]
fn try_get_by<I: ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> { fn try_get_by<I: ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
TmdbRepr::try_get_by(res, index).map(|id| Self { TmdbRepr::try_get_by(res, index).map(|id| Self {
id, id,
@@ -129,6 +157,7 @@ impl<T> TryGetable for Id<T> {
#[cfg(feature = "sea-orm")] #[cfg(feature = "sea-orm")]
impl<T> TryFromU64 for Id<T> { impl<T> TryFromU64 for Id<T> {
#[inline]
fn try_from_u64(n: u64) -> Result<Self, DbErr> { fn try_from_u64(n: u64) -> Result<Self, DbErr> {
TmdbRepr::try_from_u64(n).map(|id| Self { TmdbRepr::try_from_u64(n).map(|id| Self {
id, id,
@@ -139,6 +168,7 @@ impl<T> TryFromU64 for Id<T> {
#[cfg(feature = "sea-orm")] #[cfg(feature = "sea-orm")]
impl<T> Nullable for Id<T> { impl<T> Nullable for Id<T> {
#[inline]
fn null() -> Value { fn null() -> Value {
TmdbRepr::null() TmdbRepr::null()
} }
@@ -146,34 +176,34 @@ impl<T> Nullable for Id<T> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
#[cfg(feature = "sea-orm")] #[cfg(feature = "sea-orm")]
fn test_sea_orm() { #[expect(dead_code, reason = "structs test derive macros")]
mod test_seaorm {
use sea_orm::{ use sea_orm::{
ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait, ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait,
EnumIter, PrimaryKeyTrait, EnumIter, PrimaryKeyTrait,
}; };
use super::Id; use super::super::Id;
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, DeriveEntityModel)] #[derive(Debug, Clone, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "ids")] #[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)] #[sea_orm(primary_key, auto_increment = false)]
id: Id<Model>, id: Id<Model>,
nullable: Option<Id<Model>>, nullable: Option<Id<Model>>,
} }
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
#[allow(dead_code)]
#[derive(Debug, EnumIter, DeriveRelation)] #[derive(Debug, EnumIter, DeriveRelation)]
pub enum Relation {} enum Relation {}
} }
#[test] #[test]
fn test_serde() { fn serde() {
use super::Id; use super::Id;
let id: Id<()> = Id::from_raw(1234); 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)] #[doc(hidden)]
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum Collection {} pub enum Collection {}
/// Type alias for a collection ID /// Type alias for a collection ID.
pub type CollectionId = Id<Collection>; pub type CollectionId = Id<Collection>;
impl From<CollectionId> for flix_model::id::CollectionId { impl From<CollectionId> for flix_model::id::CollectionId {
#[inline]
fn from(value: CollectionId) -> Self { fn from(value: CollectionId) -> Self {
Self::from_raw(value.into_raw().into()) Self::from_raw(value.into_raw().into())
} }
} }
impl TryFrom<flix_model::id::CollectionId> for CollectionId { 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> { fn try_from(value: flix_model::id::CollectionId) -> Result<Self, Self::Error> {
value.into_raw().try_into().map(Self::from_raw) value.into_raw().try_into().map(Self::from_raw)
} }
} }
#[doc(hidden)] #[doc(hidden)]
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum Movie {} pub enum Movie {}
/// Type alias for a movie ID /// Type alias for a movie ID.
pub type MovieId = Id<Movie>; pub type MovieId = Id<Movie>;
impl From<MovieId> for flix_model::id::MovieId { impl From<MovieId> for flix_model::id::MovieId {
#[inline]
fn from(value: MovieId) -> Self { fn from(value: MovieId) -> Self {
Self::from_raw(value.into_raw().into()) Self::from_raw(value.into_raw().into())
} }
} }
impl TryFrom<flix_model::id::MovieId> for MovieId { 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> { fn try_from(value: flix_model::id::MovieId) -> Result<Self, Self::Error> {
value.into_raw().try_into().map(Self::from_raw) value.into_raw().try_into().map(Self::from_raw)
} }
} }
#[doc(hidden)] #[doc(hidden)]
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum Show {} pub enum Show {}
/// Type alias for a show ID /// Type alias for a show ID.
pub type ShowId = Id<Show>; pub type ShowId = Id<Show>;
impl From<ShowId> for flix_model::id::ShowId { impl From<ShowId> for flix_model::id::ShowId {
#[inline]
fn from(value: ShowId) -> Self { fn from(value: ShowId) -> Self {
Self::from_raw(value.into_raw().into()) Self::from_raw(value.into_raw().into())
} }
} }
impl TryFrom<flix_model::id::ShowId> for ShowId { 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> { fn try_from(value: flix_model::id::ShowId) -> Result<Self, Self::Error> {
value.into_raw().try_into().map(Self::from_raw) 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 core::time::Duration;
use serde::{Deserialize, Deserializer}; use serde::{Deserialize as _, Deserializer};
use url::Url; use url::Url;
pub mod id; pub mod id;
@@ -20,14 +20,22 @@ pub use movie::*;
pub use season::*; pub use season::*;
pub use show::*; 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> fn duration_from_minutes<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where where
D: Deserializer<'de>, D: Deserializer<'de>,
{ {
let minutes = u64::deserialize(deserializer).unwrap_or(0); let minutes = u64::deserialize(deserializer)?;
Ok(Duration::from_secs(minutes.saturating_mul(60))) 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> fn still_url_from_path<'de, D>(deserializer: D) -> Result<Option<Url>, D::Error>
where where
D: Deserializer<'de>, D: Deserializer<'de>,
@@ -38,10 +46,6 @@ where
let path = Option::<&str>::deserialize(deserializer)?; let path = Option::<&str>::deserialize(deserializer)?;
Ok(path.and_then(|path| { Ok(path.and_then(|path| {
Url::from_str(&format!( Url::from_str(&format!("{TMDB_IMAGE_BASE}{TMDB_IMAGE_QUALITY}{path}")).ok()
"{}{}{}",
TMDB_IMAGE_BASE, TMDB_IMAGE_QUALITY, path
))
.ok()
})) }))
} }
+40 -11
View File
@@ -1,3 +1,5 @@
//! TMDB movie model.
use core::time::Duration; use core::time::Duration;
use chrono::NaiveDate; use chrono::NaiveDate;
@@ -5,33 +7,35 @@ use chrono::NaiveDate;
use super::duration_from_minutes; use super::duration_from_minutes;
use super::id::{CollectionId, MovieId}; use super::id::{CollectionId, MovieId};
/// A deserialized Movie from the TMDB API /// A deserialized Movie from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)] #[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Movie { pub struct Movie {
/// The movie's TMDB ID /// The movie's TMDB ID.
pub id: MovieId, pub id: MovieId,
/// The movie's collection, if it exists /// The movie's collection, if it exists.
#[serde(rename = "belongs_to_collection")] #[serde(rename = "belongs_to_collection")]
pub collection: Option<InCollection>, pub collection: Option<InCollection>,
/// The movie's title /// The movie's title.
pub title: String, pub title: String,
/// The movie's tagline /// The movie's tagline.
pub tagline: String, pub tagline: String,
/// The movie's overview /// The movie's overview.
pub overview: String, pub overview: String,
/// The movie's release date /// The movie's release date.
pub release_date: NaiveDate, pub release_date: NaiveDate,
/// The movie's runtime /// The movie's runtime.
#[serde(deserialize_with = "duration_from_minutes")] #[serde(deserialize_with = "duration_from_minutes")]
pub runtime: Duration, 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)] #[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct InCollection { pub struct InCollection {
/// The collection's TMDB ID /// The collection's TMDB ID.
pub id: CollectionId, pub id: CollectionId,
/// The collection's title /// The collection's title.
#[serde(rename = "name")] #[serde(rename = "name")]
pub title: String, pub title: String,
} }
@@ -43,3 +47,28 @@ pub struct InCollection {
// TODO: Company // TODO: Company
// pub companies: Vec<Company> // pub companies: Vec<Company>
// where: struct Company { id, name } // 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 chrono::NaiveDate;
use flix_model::numbers::SeasonNumber; use flix_model::numbers::SeasonNumber;
/// A deserialized Season from the TMDB API /// A deserialized Season from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)] #[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Season { pub struct Season {
/// The season's number /// The season's number.
pub season_number: SeasonNumber, pub season_number: SeasonNumber,
/// The season's title /// The season's title.
#[serde(rename = "name")] #[serde(rename = "name")]
pub title: String, pub title: String,
/// The season's overview /// The season's overview.
pub overview: String, pub overview: String,
/// The season's air date /// The season's air date.
pub air_date: NaiveDate, pub air_date: NaiveDate,
/// The number of episodes in this season /// The number of episodes in this season.
pub episodes: Vec<FakeEpisode>, pub episodes: Vec<FakeEpisode>,
} }
/// A placeholder struct for parsing the episodes list for a season /// A placeholder struct for parsing the episodes list for a season.
#[derive(Debug, Clone, serde::Deserialize)] #[derive(Debug, Clone, Copy, serde::Deserialize)]
#[non_exhaustive]
#[expect(clippy::empty_structs_with_brackets, reason = "might add fields later")]
pub struct FakeEpisode {} 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 chrono::NaiveDate;
use super::id::ShowId; use super::id::ShowId;
/// A deserialized Show from the TMDB API /// A deserialized Show from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)] #[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Show { pub struct Show {
/// The show's TMDB ID /// The show's TMDB ID.
pub id: ShowId, pub id: ShowId,
/// The show's title /// The show's title.
#[serde(rename = "name")] #[serde(rename = "name")]
pub title: String, pub title: String,
/// The show's tagline /// The show's tagline.
pub tagline: String, pub tagline: String,
/// The show's overview /// The show's overview.
pub overview: String, pub overview: String,
/// The show's first air date /// The show's first air date.
pub first_air_date: NaiveDate, pub first_air_date: NaiveDate,
/// The show's last air date /// The show's last air date.
pub last_air_date: NaiveDate, 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, pub number_of_episodes: u32,
/// The number of seasons in this show /// The number of seasons in this show.
pub number_of_seasons: u32, pub number_of_seasons: u32,
} }
@@ -35,3 +38,24 @@ pub struct Show {
// TODO: Company // TODO: Company
// pub companies: Vec<Company> // pub companies: Vec<Company>
// where: struct Company { id, name } // 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] [toolchain]
channel = "stable" channel = "1.98"