diff --git a/Cargo.lock b/Cargo.lock index d25d83a..bda8dfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,15 +13,45 @@ dependencies = [ [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -49,35 +79,83 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "backtrace" @@ -91,7 +169,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -101,16 +179,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "bitflags" -version = "2.9.1" +name = "base64ct" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] [[package]] name = "bumpalo" -version = "3.18.1" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" @@ -120,18 +222,19 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.26" +version = "1.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac" +checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" dependencies = [ + "find-msvc-tools", "shlex", ] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "cfg_aliases" @@ -141,19 +244,21 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ + "iana-time-zone", "num-traits", "serde", + "windows-link 0.2.0", ] [[package]] name = "clap" -version = "4.5.39" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f" +checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" dependencies = [ "clap_builder", "clap_derive", @@ -161,9 +266,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.39" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51" +checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" dependencies = [ "anstream", "anstyle", @@ -173,11 +278,11 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.32" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn", @@ -185,9 +290,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "colorchoice" @@ -195,6 +300,133 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -206,6 +438,21 @@ dependencies = [ "syn", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -213,42 +460,121 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "flix" -version = "0.0.8" +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" dependencies = [ - "chrono", + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" + +[[package]] +name = "flix" +version = "0.0.9" +dependencies = [ + "flix-db", + "flix-fs", + "flix-model", "flix-tmdb", - "serde", - "thiserror", ] [[package]] name = "flix-cli" -version = "0.0.8" +version = "0.0.9" dependencies = [ "anyhow", + "chrono", "clap", "flix", - "flix-tmdb", "futures", "home", + "sea-orm", "serde", "tokio", "toml", ] [[package]] -name = "flix-tmdb" -version = "0.0.8" +name = "flix-db" +version = "0.0.9" dependencies = [ "chrono", - "reqwest", + "flix-model", + "flix-tmdb", + "sea-orm", + "sea-orm-migration", + "seamantic", + "tokio", +] + +[[package]] +name = "flix-fs" +version = "0.0.9" +dependencies = [ + "async-stream", + "flix-model", + "regex", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "flix-model" +version = "0.0.9" +dependencies = [ + "seamantic", "serde", "thiserror", +] + +[[package]] +name = "flix-tmdb" +version = "0.0.9" +dependencies = [ + "chrono", + "flix-model", + "governor", + "nonzero_ext", + "reqwest", + "sea-orm", + "serde", + "serde_test", + "thiserror", "url", "url-macro", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -256,10 +582,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "form_urlencoded" -version = "1.2.1" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -294,6 +626,28 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -312,6 +666,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -319,10 +679,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-io", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", ] [[package]] @@ -334,7 +707,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -348,7 +721,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", "wasm-bindgen", ] @@ -359,10 +732,63 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] -name = "hashbrown" -version = "0.15.3" +name = "glob" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "governor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "444405bbb1a762387aa22dd569429533b54a1d8759d35d3b64cb39b0293eaa19" +dependencies = [ + "cfg-if", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.3", + "hashbrown 0.15.5", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "rand 0.9.2", + "smallvec", + "spinning_top", + "web-time", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] name = "heck" @@ -370,6 +796,30 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.11" @@ -421,18 +871,20 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "http", "http-body", "httparse", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -452,14 +904,14 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.2", ] [[package]] name = "hyper-util" -version = "0.1.14" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "base64", "bytes", @@ -479,6 +931,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -566,10 +1042,16 @@ dependencies = [ ] [[package]] -name = "idna" -version = "1.0.3" +name = "ident_case" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -588,12 +1070,34 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.9.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.0", +] + +[[package]] +name = "inherent" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags", + "cfg-if", + "libc", ] [[package]] @@ -626,19 +1130,56 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" dependencies = [ "once_cell", "wasm-bindgen", ] [[package]] -name = "libc" -version = "0.2.172" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] [[package]] name = "litemap" @@ -647,10 +1188,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] -name = "log" -version = "0.4.27" +name = "lock_api" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "lru-slab" @@ -659,22 +1210,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] -name = "memchr" -version = "2.7.4" +name = "matchers" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] [[package]] -name = "mime" -version = "0.3.17" +name = "md-5" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "miniz_oxide" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", ] @@ -686,10 +1250,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -697,6 +1304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -721,10 +1329,81 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" [[package]] -name = "percent-encoding" -version = "2.3.1" +name = "ordered-float" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" @@ -739,10 +1418,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] -name = "potential_utf" -version = "0.1.2" +name = "pkcs1" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" dependencies = [ "zerovec", ] @@ -757,19 +1469,54 @@ dependencies = [ ] [[package]] -name = "proc-macro2" -version = "1.0.95" +name = "proc-macro-error-attr2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] [[package]] -name = "quinn" -version = "0.11.8" +name = "proc-macro2-diagnostics" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", @@ -787,14 +1534,14 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.12" +version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "bytes", "getrandom 0.3.3", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", @@ -808,16 +1555,16 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.12" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -831,18 +1578,39 @@ dependencies = [ [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" -version = "0.9.1" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "rand_chacha", - "rand_core", + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -852,7 +1620,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", ] [[package]] @@ -865,10 +1642,48 @@ dependencies = [ ] [[package]] -name = "reqwest" -version = "0.12.19" +name = "redox_syscall" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "reqwest" +version = "0.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64", "bytes", @@ -879,11 +1694,8 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", - "ipnet", "js-sys", "log", - "mime", - "once_cell", "percent-encoding", "pin-project-lite", "quinn", @@ -902,7 +1714,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.2", ] [[package]] @@ -920,10 +1732,30 @@ dependencies = [ ] [[package]] -name = "rustc-demangle" -version = "0.1.24" +name = "rsa" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -933,9 +1765,9 @@ checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustls" -version = "0.23.27" +version = "0.23.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" dependencies = [ "once_cell", "ring", @@ -957,9 +1789,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.3" +version = "0.103.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" dependencies = [ "ring", "rustls-pki-types", @@ -968,9 +1800,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -979,19 +1811,188 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] -name = "serde" -version = "1.0.219" +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sea-bae" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f694a6ab48f14bc063cfadff30ab551d3c7e46d8f81836c51989d548f44a2a25" +dependencies = [ + "heck 0.4.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sea-orm" +version = "2.0.0-rc.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ddcd2f974a1e5ecf9fed287dd939d4801d78fea4f68b9870498fa867e12e589" +dependencies = [ + "async-stream", + "async-trait", + "chrono", + "futures-util", + "log", + "ouroboros", + "sea-orm-macros", + "sea-query", + "sea-query-sqlx", + "serde", + "sqlx", + "strum", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "sea-orm-cli" +version = "2.0.0-rc.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b63ef6eedd012d359b947b5bb283549ceae56ea44b92d0da884c3e8d605439" +dependencies = [ + "chrono", + "glob", + "regex", + "sea-schema", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "sea-orm-macros" +version = "2.0.0-rc.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3019198b9202bbc96c389d5dfe33f0ba98032ff8ad293fe9c2e11b7c6d992830" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "sea-bae", + "syn", + "unicode-ident", +] + +[[package]] +name = "sea-orm-migration" +version = "2.0.0-rc.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ed01c2f512034f765fb90ffccdf82af8cd948b327c60559ac55d3fb79a119c" +dependencies = [ + "async-trait", + "sea-orm", + "sea-orm-cli", + "sea-schema", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sea-query" +version = "1.0.0-rc.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdeac8832b5b36a57b38ef6d2956cd6244c04508669f2a932439c8ca25dbad0f" +dependencies = [ + "chrono", + "inherent", + "ordered-float", + "sea-query-derive", +] + +[[package]] +name = "sea-query-derive" +version = "1.0.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217e9422de35f26c16c5f671fce3c075a65e10322068dbc66078428634af6195" +dependencies = [ + "darling", + "heck 0.4.1", + "proc-macro2", + "quote", + "syn", + "thiserror", +] + +[[package]] +name = "sea-query-sqlx" +version = "0.8.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68873fa1776b4c25a26e7679f8ee22332978c721168ec1b0b32b6583d5a9381d" +dependencies = [ + "chrono", + "sea-query", + "sqlx", +] + +[[package]] +name = "sea-schema" +version = "0.17.0-rc.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfb37ec7279ebca303db0da04f0c4db9e869d569ae00bb31114bc945a5d848c6" +dependencies = [ + "futures", + "sea-query", + "sea-query-sqlx", + "sea-schema-derive", + "sqlx", +] + +[[package]] +name = "sea-schema-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdc8729c37fdbf88472f97fd470393089f997a909e535ff67c544d18cfccf0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "seamantic" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4b388ca1d150dd2f3cceff59e910543fe7c638f44a9cb6223ace53d06d38e" +dependencies = [ + "sea-orm", + "sea-orm-migration", +] + +[[package]] +name = "serde" +version = "1.0.225" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.225" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" dependencies = [ "proc-macro2", "quote", @@ -1000,21 +2001,31 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_test" +version = "1.0.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f901ee573cab6b3060453d2d5f0bae4e6d628c23c0a962ff9b5f1d7c8d4f1ed" dependencies = [ "serde", ] @@ -1031,6 +2042,37 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1038,28 +2080,260 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "slab" -version = "0.4.9" +name = "signature" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "autocfg", + "digest", + "rand_core 0.6.4", ] +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket2" -version = "0.5.10" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", ] [[package]] @@ -1068,12 +2342,35 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + [[package]] name = "subtle" version = "2.6.1" @@ -1082,9 +2379,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.101" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -1113,24 +2410,33 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -1143,9 +2449,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -1158,18 +2464,20 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.45.1" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "pin-project-lite", + "slab", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1185,54 +2493,55 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" dependencies = [ "rustls", "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" -version = "0.8.23" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" dependencies = [ - "serde", + "serde_core", "serde_spanned", "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", + "toml_parser", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_datetime" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +dependencies = [ + "winnow", +] [[package]] name = "tower" @@ -1285,10 +2594,23 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.34" @@ -1298,6 +2620,21 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "once_cell", + "regex-automata", + "sharded-slab", + "thread_local", + "tracing", + "tracing-core", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -1305,10 +2642,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] -name = "unicode-ident" -version = "1.0.18" +name = "typenum" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" [[package]] name = "untrusted" @@ -1318,13 +2682,14 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -1348,6 +2713,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -1359,36 +2736,52 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" dependencies = [ - "wit-bindgen-rt", + "wasip2", ] [[package]] -name = "wasm-bindgen" -version = "0.2.100" +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" dependencies = [ "bumpalo", "log", @@ -1400,9 +2793,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" dependencies = [ "cfg-if", "js-sys", @@ -1413,9 +2806,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1423,9 +2816,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" dependencies = [ "proc-macro2", "quote", @@ -1436,18 +2829,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" dependencies = [ "js-sys", "wasm-bindgen", @@ -1465,20 +2858,113 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.0" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.2", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.0", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -1487,7 +2973,31 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", ] [[package]] @@ -1496,58 +3006,159 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link 0.1.3", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -1555,22 +3166,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "winnow" -version = "0.7.10" +name = "windows_x86_64_msvc" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" -dependencies = [ - "memchr", -] +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "winnow" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags", -] +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" @@ -1578,6 +3189,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.0" @@ -1604,18 +3221,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.25" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.25" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", @@ -1662,9 +3279,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" dependencies = [ "yoke", "zerofrom", diff --git a/Cargo.toml b/Cargo.toml index 8744666..e136180 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ rust-version = "1.85.0" arithmetic_overflow = "forbid" missing_docs = "forbid" unsafe_code = "forbid" +unused_doc_comments = "forbid" [workspace.lints.clippy] arithmetic_side_effects = "forbid" @@ -34,18 +35,33 @@ overflow-checks = true strip = "debuginfo" [workspace.dependencies] -flix = { path = "crates/flix", version = "=0.0.8", default-features = false } -flix-tmdb = { path = "crates/tmdb", version = "=0.0.8", default-features = false } +flix = { path = "crates/flix", version = "=0.0.9", default-features = false } +flix-cli = { path = "crates/cli", version = "=0.0.9", default-features = false } +flix-db = { path = "crates/db", version = "=0.0.9", default-features = false } +flix-fs = { path = "crates/fs", version = "=0.0.9", default-features = false } +flix-model = { path = "crates/model", version = "=0.0.9", default-features = false } +flix-tmdb = { path = "crates/tmdb", version = "=0.0.9", default-features = false } + +seamantic = { version = "0.0.5", default-features = false } + +sea-orm = { version = "2.0.0-rc.7", default-features = false } +sea-orm-migration = { version = "2.0.0-rc.7", default-features = false } anyhow = { version = "^1", default-features = false } +async-stream = { version = "^0.3", default-features = false } chrono = { version = "^0.4", default-features = false } clap = { version = "^4", default-features = false, features = ["std"] } futures = { version = "^0.3", default-features = false } +governor = { version = "^0.10", default-features = false } home = { version = "^0.5", default-features = false } +nonzero_ext = { version = "^0.3", default-features = false } +regex = { version = "^1", default-features = false } reqwest = { version = "^0.12", default-features = false } serde = { version = "^1", default-features = false } +serde_test = { version = "^1", default-features = false } thiserror = { version = "^2", default-features = false } tokio = { version = "^1", default-features = false } -toml = { version = "^0.8", default-features = false } +tokio-stream = { version = "^0.1", default-features = false } +toml = { version = "^0.9", default-features = false } url = { version = "^2", default-features = false } url-macro = { version = "^0.2", default-features = false } diff --git a/README.md b/README.md index e9b0f07..aa5c965 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ Libraries and tools for dealing with media metadata - build: `cargo hack --feature-powerset build` - clippy: `cargo hack --feature-powerset clippy -- -D warnings` +- test: `cargo hack --feature-powerset test` +- test old: `cargo +1.85 hack --feature-powerset test` - fmt: `cargo fmt --check` - docs: `RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --all-features` - install: `cargo install --path crates/cli` -- publish: `cargo publish --dry-run -p flix-tmdb` -- publish: `cargo publish --dry-run -p flix` -- publish: `cargo publish --dry-run -p flix-cli` +- publish: `cargo publish --dry-run --workspace` diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index f9116b3..168b50d 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "flix-cli" -version = "0.0.8" +version = "0.0.9" categories = ["command-line-utilities"] -description = "CLI for interacting with flix media" +description = "CLI for interacting with a flix database" repository = "https://github.com/QuantumShade/flix" authors.workspace = true @@ -11,6 +11,10 @@ edition.workspace = true license-file.workspace = true rust-version.workspace = true +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + [[bin]] doc = false name = "flix" @@ -35,9 +39,9 @@ unwrap_used = "deny" [dependencies] flix = { workspace = true, features = ["tmdb"] } -flix-tmdb = { workspace = true } anyhow = { workspace = true } +chrono = { workspace = true, features = ["now"] } clap = { workspace = true, features = [ "derive", "color", @@ -46,8 +50,9 @@ clap = { workspace = true, features = [ "suggestions", "usage", ] } -home = { workspace = true } -serde = { workspace = true, features = ["derive"] } futures = { workspace = true } +home = { workspace = true } +sea-orm = { workspace = true, features = ["runtime-tokio"] } +serde = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["rt", "fs", "macros"] } -toml = { workspace = true, features = ["display", "parse"] } +toml = { workspace = true, features = ["parse", "serde"] } diff --git a/crates/cli/README.md b/crates/cli/README.md index 27b13f6..7098d7d 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -2,4 +2,10 @@ [![Crates Version](https://img.shields.io/crates/v/flix-cli.svg)](https://crates.io/crates/flix-cli) -CLI for interacting with flix media +CLI for interacting with a flix database + +## Commands +```sh +cargo run -- init +cargo run -- add tmdb movie +``` diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index 88e3c07..a81b44c 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use anyhow::{Result, anyhow}; use clap::{Parser, Subcommand}; pub mod tmdb; @@ -11,18 +12,35 @@ pub struct Cli { #[arg(short, long, value_name = "FILE", default_value = "~/.flix")] config: PathBuf, + /// Use a custom database file + #[arg(short, long, value_name = "DATABASE", default_value = "./flix.db")] + database: PathBuf, + #[command(subcommand)] command: Command, } impl Cli { pub fn config_path(&self) -> PathBuf { + fn expect_home_dir() -> PathBuf { + #[allow(clippy::expect_used)] + home::home_dir().expect("you do not have a home directory") + } + match self.config.strip_prefix("~/") { Ok(path) => expect_home_dir().join(path), Err(_) => self.config.to_owned(), } } + pub fn database_path(&self) -> Result { + self.database + .as_os_str() + .to_str() + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow!(".as_os_str().to_str()")) + } + pub fn command(self) -> Command { self.command } @@ -30,30 +48,35 @@ impl Cli { #[derive(Subcommand)] pub enum Command { - /// Print a flix manifest - Print { + /// Initialize a new database + Init, + /// Add new items to the database + Add { #[command(subcommand)] command: BackendCommand, }, - /// Write a flix manifest if the destination does not exist - Write { - /// Overwrite the destination - #[arg(short, long, default_value_t = false)] - force: bool, - - /// Change the destination - #[arg(short, long, value_name = "FILE", default_value = "flix.toml")] - output: PathBuf, - - #[command(subcommand)] - command: BackendCommand, - }, - /// Update a flix manifest + /// Update an existing item in the database Update { + #[command(subcommand)] + command: BackendCommand, + }, + /// Delete an existing item in the database + Delete { + #[command(subcommand)] + command: BackendCommand, + }, + /// Create a toml backup of the database + Backup { /// Change the destination - #[arg(short, long, value_name = "FILE", default_value = "flix.toml")] + #[arg(short, long, value_name = "FILE", default_value = "./flix.toml")] output: PathBuf, }, + /// Create a database from a toml backup + Restore { + /// Change the source + #[arg(short, long, value_name = "FILE", default_value = "./flix.toml")] + input: PathBuf, + }, } #[derive(Subcommand)] @@ -70,8 +93,3 @@ impl From for BackendCommand { Self::Tmdb { command: value } } } - -fn expect_home_dir() -> PathBuf { - #[allow(clippy::expect_used)] - home::home_dir().expect("you do not have a home directory") -} diff --git a/crates/cli/src/cli/tmdb.rs b/crates/cli/src/cli/tmdb.rs index 2f66e4b..15c9ba6 100644 --- a/crates/cli/src/cli/tmdb.rs +++ b/crates/cli/src/cli/tmdb.rs @@ -1,3 +1,6 @@ +use flix::model::numbers::{EpisodeNumber, SeasonNumber}; +use flix::tmdb::model::id::RawId; + use clap::Subcommand; #[derive(Subcommand)] @@ -5,35 +8,35 @@ pub enum Command { /// Process a TMDB collection Collection { #[arg(value_name = "TMDB_ID")] - id: u32, + id: RawId, }, /// Process a TMDB movie Movie { #[arg(value_name = "TMDB_ID")] - id: u32, + id: RawId, }, /// Process a TMDB show Show { #[arg(value_name = "TMDB_ID")] - id: u32, + id: RawId, }, /// Process a TMDB season Season { #[arg(value_name = "TMDB_ID")] - id: u32, + id: RawId, #[arg(value_name = "SEASON_NUM")] - season: u32, + season: SeasonNumber, }, /// Process a TMDB episode #[command(trailing_var_arg = true)] Episode { #[arg(value_name = "TMDB_ID")] - id: u32, + id: RawId, #[arg(value_name = "SEASON_NUM")] - season: u32, + season: SeasonNumber, #[arg(value_name = "EPISODE_NUM")] - episode: u32, + episode: EpisodeNumber, #[arg(value_name = "...")] - episodes: Vec, + episodes: Vec, }, } diff --git a/crates/cli/src/db.rs b/crates/cli/src/db.rs new file mode 100644 index 0000000..d0c2468 --- /dev/null +++ b/crates/cli/src/db.rs @@ -0,0 +1,27 @@ +use flix::db::connection::Connection; + +use anyhow::{Context, Result, bail}; +use sea_orm::{ConnectOptions, Database}; +use tokio::fs; + +async fn connect(string: String) -> Result { + Connection::try_from( + Database::connect(ConnectOptions::new(string)) + .await + .context("Database::connect")?, + ) + .await + .context("Connection::try_from") +} + +pub async fn open(database_path: String) -> Result { + connect(format!("sqlite:{database_path}?mode=rw")).await +} + +pub async fn open_new(database_path: String) -> Result { + if fs::try_exists(&database_path).await? { + bail!("database already exists"); + } + + connect(format!("sqlite:{database_path}?mode=rwc")).await +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs new file mode 100644 index 0000000..4a69339 --- /dev/null +++ b/crates/cli/src/lib.rs @@ -0,0 +1,3 @@ +//! Placeholder + +#![cfg_attr(docsrs, feature(doc_cfg))] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 3044e92..a355f6f 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,11 +1,10 @@ -use std::path::Path; +use std::path::PathBuf; -use flix_tmdb::Client; +use flix::tmdb::Client; use anyhow::{Context, Result}; use clap::Parser; use tokio::fs; -use tokio::io::AsyncWriteExt; mod cli; use cli::{BackendCommand, Cli, Command}; @@ -13,8 +12,8 @@ use cli::{BackendCommand, Cli, Command}; mod config; use config::Config; +mod db; mod run; -use run::flix::FlixObject; #[tokio::main(flavor = "current_thread")] async fn main() -> Result<()> { @@ -25,67 +24,72 @@ async fn main() -> Result<()> { let config: Config = toml::from_str(config.as_str()) .with_context(|| format!("could not parse config: {:?}", cli.config_path()))?; + let database_path = cli.database_path()?; + let client = Client::new(config.tmdb().bearer_token().to_owned()); match cli.command() { - Command::Print { command } => exec_print(client, command).await?, - Command::Write { - force, - output, - command, - } => exec_write(client, force, &output, command).await?, - Command::Update { output } => exec_update(client, &output).await?, + Command::Init => exec_init(database_path).await?, + Command::Add { command } => exec_add(client, database_path, command).await?, + Command::Update { command } => exec_update(client, database_path, command).await?, + Command::Delete { command } => exec_delete(client, database_path, command).await?, + Command::Backup { output } => exec_backup(database_path, output).await?, + Command::Restore { input } => exec_restore(database_path, input).await?, } Ok(()) } -async fn exec_print(client: Client, command: BackendCommand) -> Result<()> { +async fn exec_init(database_path: String) -> Result<()> { + db::open_new(database_path).await?; + + Ok(()) +} + +async fn exec_add(client: Client, database_path: String, command: BackendCommand) -> Result<()> { + let database = db::open(database_path).await?; + match command { BackendCommand::Tmdb { command } => { - let object = run::tmdb::TmdbObject::fetch(&client, command).await?; - println!("{}", object.serialize().context("failed to serialize")?) + run::tmdb::add(client, database.as_ref(), command).await?; } } + Ok(()) } -async fn exec_write( - client: Client, - force: bool, - output: &Path, - command: BackendCommand, -) -> Result<()> { +async fn exec_update(client: Client, database_path: String, command: BackendCommand) -> Result<()> { + let database = db::open(database_path).await?; + match command { BackendCommand::Tmdb { command } => { - let object = run::tmdb::TmdbObject::fetch(&client, command).await?; - - let mut file = if force { - fs::File::create(output).await - } else { - fs::File::create_new(output).await - } - .with_context(|| format!("could not create file at path {}", output.display()))?; - file.write_all( - object - .serialize() - .context("failed to serialize tmdb object")? - .as_bytes(), - ) - .await - .with_context(|| format!("could not write to file at path {}", output.display()))?; + run::tmdb::update(client, database.as_ref(), command).await?; } } + Ok(()) } -async fn exec_update(client: Client, output: &Path) -> Result<()> { - let content = fs::read_to_string(output) - .await - .with_context(|| format!("failed to read file at path: {}", output.display()))?; - let object: FlixObject = toml::from_str(&content) - .with_context(|| format!("failed to deserialize flix file: {}", output.display()))?; +async fn exec_delete(client: Client, database_path: String, command: BackendCommand) -> Result<()> { + let database = db::open(database_path).await?; - let command = object.backend_command()?; - exec_write(client, true, output, command).await + match command { + BackendCommand::Tmdb { command } => { + run::tmdb::delete(client, database.as_ref(), command).await?; + } + } + + Ok(()) +} + +async fn exec_backup(database_path: String, output: PathBuf) -> Result<()> { + _ = database_path; + _ = output; + unimplemented!() +} + +async fn exec_restore(database_path: String, input: PathBuf) -> Result<()> { + _ = database_path; + _ = input; + unimplemented!() } diff --git a/crates/cli/src/run/flix.rs b/crates/cli/src/run/flix.rs deleted file mode 100644 index cce5c16..0000000 --- a/crates/cli/src/run/flix.rs +++ /dev/null @@ -1,75 +0,0 @@ -use flix::model::{Collection, Episode, Movie, Season, Show, Verse}; - -use anyhow::{Result, anyhow, bail}; - -use crate::cli::BackendCommand; -use crate::cli::tmdb; - -#[derive(Debug, serde::Deserialize)] -#[serde(untagged)] -pub enum FlixObject { - Collection(Collection), - Movie(Movie), - Show(Show), - Season(Season), - Episode(Episode), - // DEAD CODE: The TMDB backend does not support Verses - #[allow(dead_code)] - Verse(Verse), -} - -impl FlixObject { - pub fn backend_command(&self) -> Result { - Ok(match self { - FlixObject::Collection(collection) => { - let Some(ref tmdb) = collection.tmdb else { - bail!("missing tmdb data") - }; - tmdb::Command::Collection { id: tmdb.id.into() }.into() - } - FlixObject::Movie(movie) => { - let Some(ref tmdb) = movie.tmdb else { - bail!("missing tmdb data") - }; - tmdb::Command::Movie { id: tmdb.id.into() }.into() - } - FlixObject::Show(show) => { - let Some(ref tmdb) = show.tmdb else { - bail!("missing tmdb data") - }; - tmdb::Command::Show { id: tmdb.id.into() }.into() - } - FlixObject::Season(season) => { - let Some(ref tmdb) = season.tmdb else { - bail!("missing tmdb data") - }; - tmdb::Command::Season { - id: tmdb.show_id.into(), - season: season.season.number, - } - .into() - } - FlixObject::Episode(episode) => { - let Some(ref tmdb) = episode.tmdb else { - bail!("missing tmdb data") - }; - tmdb::Command::Episode { - id: tmdb.show_id.into(), - season: episode.episode.season, - episode: episode - .episode - .number - .primary_episode_number() - .ok_or_else(|| { - anyhow!("the episode does not have a primary episode number") - })?, - episodes: episode.episode.number.additional_episode_numbers(), - } - .into() - } - FlixObject::Verse(_) => { - bail!("verses are not handled") - } - }) - } -} diff --git a/crates/cli/src/run/mod.rs b/crates/cli/src/run/mod.rs index 9acb9e7..93cfeb6 100644 --- a/crates/cli/src/run/mod.rs +++ b/crates/cli/src/run/mod.rs @@ -1,2 +1 @@ -pub mod flix; pub mod tmdb; diff --git a/crates/cli/src/run/tmdb.rs b/crates/cli/src/run/tmdb.rs index fd64041..eba2ebd 100644 --- a/crates/cli/src/run/tmdb.rs +++ b/crates/cli/src/run/tmdb.rs @@ -1,157 +1,507 @@ -use flix::model::{ - Collection, Episode, EpisodeNumber, GenericCollection, GenericEpisode, GenericMovie, - GenericSeason, GenericShow, Movie, Season, Show, TmdbCollection, TmdbEpisode, TmdbMovie, - TmdbSeason, TmdbShow, -}; -use flix_tmdb::Client; -use flix_tmdb::model::{ - Collection as TCollection, Episode as TEpisode, Movie as TMovie, Season as TSeason, - Show as TShow, ShowId, +use std::collections::HashMap; + +use flix::db::entity; +use flix::model::id::{CollectionId, MovieId, ShowId}; +use flix::model::numbers::{EpisodeNumber, SeasonNumber}; +use flix::tmdb::Client; +use flix::tmdb::model::id::{ + CollectionId as TmdbCollectionId, MovieId as TmdbMovieId, ShowId as TmdbShowId, }; -use anyhow::{Context, Result}; -use futures::{StreamExt, TryStreamExt, stream}; +use anyhow::{Context, Result, bail}; +use chrono::Utc; +use sea_orm::ActiveValue::{NotSet, Set}; +use sea_orm::{ + ActiveModelTrait, DatabaseConnection, DbErr, EntityTrait, TransactionError, TransactionTrait, +}; use crate::cli::tmdb::Command; -pub enum TmdbObject { - Collection(TCollection), - Movie(TMovie), - Show(TShow), - Season(TSeason, ShowId), - Episode(TEpisode, Vec, u32, ShowId), -} +pub async fn add(client: Client, db: &DatabaseConnection, command: Command) -> Result<()> { + match command { + Command::Collection { id } => { + let id = TmdbCollectionId::from_raw(id); -impl TmdbObject { - pub fn serialize(self) -> Result { - Ok(match self { - TmdbObject::Collection(tmdb) => toml::to_string(&Collection { - collection: GenericCollection { - title: tmdb.title, - overview: tmdb.overview, - }, - tmdb: Some(TmdbCollection { id: tmdb.id }), - })?, - TmdbObject::Movie(tmdb) => toml::to_string(&Movie { - movie: GenericMovie { - title: tmdb.title, - overview: tmdb.overview, - genres: tmdb.genres.iter().cloned().map(|g| g.name).collect(), - release_date: tmdb.release_date, - }, - tmdb: Some(TmdbMovie { - id: tmdb.id, - genres: tmdb.genres.iter().map(|g| g.id).collect(), - }), - })?, - TmdbObject::Show(tmdb) => toml::to_string(&Show { - show: GenericShow { - title: tmdb.title, - overview: tmdb.overview, - genres: tmdb.genres.iter().cloned().map(|g| g.name).collect(), - air_date: tmdb.first_air_date, - }, - tmdb: Some(TmdbShow { - id: tmdb.id, - genres: tmdb.genres.iter().map(|g| g.id).collect(), - }), - })?, - TmdbObject::Season(tmdb, show_id) => toml::to_string(&Season { - season: GenericSeason { - number: tmdb.season_number, - title: tmdb.title, - overview: tmdb.overview, - air_date: tmdb.air_date, - }, - tmdb: Some(TmdbSeason { show_id }), - })?, - TmdbObject::Episode(tmdb, mut episode_numbers, season_number, show_id) => { - toml::to_string(&Episode { - episode: GenericEpisode { - number: if episode_numbers.is_empty() { - EpisodeNumber::Single { - number: tmdb.episode_number, - } - } else { - episode_numbers.insert(0, tmdb.episode_number); - EpisodeNumber::Multiple { - numbers: episode_numbers, - } - }, - season: season_number, - title: tmdb.title, - overview: tmdb.overview, - air_date: tmdb.air_date, - }, - tmdb: Some(TmdbEpisode { show_id }), - })? + let collection = entity::tmdb::collections::Entity::find_by_id(id) + .one(db) + .await?; + if collection.is_some() { + bail!("collection already exists"); } - }) - } -} -impl TmdbObject { - pub async fn fetch(client: &Client, command: Command) -> Result { - Ok(match command { - Command::Collection { id } => Self::Collection( - client - .collections() - .get_details(id, None) - .await - .with_context(|| format!("could not get collection details for '{id}'"))?, - ), - Command::Movie { id } => Self::Movie( - client - .movies() - .get_details(id, None) - .await - .with_context(|| format!("could not get movie details for '{id}'"))?, - ), - Command::Show { id } => Self::Show( - client - .shows() - .get_details(id, None) - .await - .with_context(|| format!("could not get show details for '{id}'"))?, - ), - Command::Season { id, season } => Self::Season( - client + let collection = client + .collections() + .get_details(id, None) + .await + .with_context(|| format!("collections().get_details({})", id.into_raw()))?; + + let result: Result> = db + .transaction(|txn| { + Box::pin(async move { + let flix = entity::info::collections::ActiveModel { + id: NotSet, + title: Set(collection.title), + overview: Set(collection.overview), + } + .insert(txn) + .await?; + + entity::tmdb::collections::ActiveModel { + tmdb_id: Set(id), + flix_id: Set(flix.id), + last_update: Set(Utc::now().date_naive()), + 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: {}", 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 result: Result> = db + .transaction(|txn| { + Box::pin(async move { + let flix = entity::info::movies::ActiveModel { + id: NotSet, + title: Set(movie.title), + tagline: Set(movie.tagline), + overview: Set(movie.overview), + date: Set(movie.release_date), + } + .insert(txn) + .await?; + + entity::tmdb::movies::ActiveModel { + tmdb_id: Set(id), + flix_id: Set(flix.id), + last_update: Set(Utc::now().date_naive()), + runtime: Set(movie.runtime.into()), + collection: 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: {}", 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 = client .seasons() .get_details(id, season, None) .await - .with_context(|| format!("could not get show details for '{id}' S{season}"))?, - id.into(), - ), - Command::Episode { - id, - season, - episode, - episodes, - } => { - let mut episode = client + .with_context(|| { + format!("seasons().get_details({}, {})", id.into_raw(), season) + })?; + if season.air_date > Utc::now().date_naive() { + eprintln!( + "skipping season ({}, {})", + id.into_raw(), + season.season_number + ); + break; + } + + let Ok(number_of_episodes) = EpisodeNumber::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 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 result: Result> = db + .transaction(|txn| { + Box::pin(async move { + let flix = entity::info::shows::ActiveModel { + id: NotSet, + title: Set(show.title), + tagline: Set(show.tagline), + overview: Set(show.overview), + date: Set(show.first_air_date), + } + .insert(txn) + .await?; + + entity::tmdb::shows::ActiveModel { + tmdb_id: Set(id), + flix_id: Set(flix.id), + last_update: Set(Utc::now().date_naive()), + number_of_seasons: Set(show.number_of_seasons), + } + .insert(txn) + .await?; + + for season in seasons { + entity::info::seasons::ActiveModel { + show: Set(flix.id), + season: 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().date_naive()), + } + .insert(txn) + .await?; + } + + for (season, episodes) in episodes { + for episode in episodes { + entity::info::episodes::ActiveModel { + show: Set(flix.id), + season: Set(season), + episode: 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().date_naive()), + 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: {}", 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) = EpisodeNumber::try_from(season.episodes.len()) else { + bail!( + "could not convert {} to an EpisodeNumber", + season.episodes.len() + ) + }; + + for episode in 1..=number_of_episodes { + let Ok(episode) = client .episodes() - .get_details(id, season, episode, None) + .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> = db + .transaction(|txn| { + Box::pin(async move { + entity::info::seasons::ActiveModel { + show: Set(show.flix_id), + season: 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().date_naive()), + } + .insert(txn) + .await?; + + for episode in episodes { + entity::info::episodes::ActiveModel { + show: Set(show.flix_id), + season: Set(season_number), + episode: 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().date_naive()), + 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!("could not get show details for '{id}' S{season}E{episode}") + format!("episodes().get_details({}, {})", id.into_raw(), season) })?; - let title = stream::once(async { Ok(episode.title) }) - .chain(stream::iter(episodes.clone()).then(|episode| async move { - client - .episodes() - .get_details(id, season, episode, None) - .await - .with_context(|| { - format!("could not get show details for '{id}' S{season}E{episode}") - }) - .map(|episode| episode.title) - })) - .try_collect::>() - .await? - .join(" + "); - episode.title = title; - Self::Episode(episode, episodes, season, id.into()) + + let result: Result<(), TransactionError> = db + .transaction(|txn| { + Box::pin(async move { + entity::info::episodes::ActiveModel { + show: Set(flix_id), + season: Set(season), + episode: 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().date_naive()), + 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(()) + } } } + +pub async fn update(client: Client, database: &DatabaseConnection, command: Command) -> Result<()> { + _ = client; + _ = database; + _ = command; + unimplemented!("updates") +} + +pub async fn delete(client: Client, database: &DatabaseConnection, command: Command) -> Result<()> { + _ = client; + _ = database; + _ = command; + unimplemented!("deletions") +} diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml new file mode 100644 index 0000000..45b419c --- /dev/null +++ b/crates/db/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "flix-db" +version = "0.0.9" + +categories = [] +description = "Types for storing persistent data about media" +repository = "https://github.com/QuantumShade/flix" + +authors.workspace = true +edition.workspace = true +license-file.workspace = true +rust-version.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[features] +default = [] +tmdb = ["dep:flix-tmdb"] + +[dependencies] +flix-model = { workspace = true } + +flix-tmdb = { workspace = true, optional = true, features = ["sea-orm"] } + +seamantic = { workspace = true, features = ["sqlite"] } + +chrono = { workspace = true } +sea-orm = { workspace = true, features = ["with-chrono"] } +sea-orm-migration = { workspace = true } + +[dev-dependencies] +sea-orm-migration = { workspace = true, features = ["runtime-tokio-rustls"] } +tokio = { version = "^1", default-features = false, features = [ + "rt", + "macros", +] } diff --git a/crates/db/README.md b/crates/db/README.md new file mode 100644 index 0000000..417b294 --- /dev/null +++ b/crates/db/README.md @@ -0,0 +1,5 @@ +# flix-db + +[![Crates Version](https://img.shields.io/crates/v/flix-db.svg)](https://crates.io/crates/flix-db) + +A library providing types for storing persistent data about media diff --git a/crates/db/src/connection.rs b/crates/db/src/connection.rs new file mode 100644 index 0000000..c2b3ada --- /dev/null +++ b/crates/db/src/connection.rs @@ -0,0 +1,22 @@ +//! Types and functions related to [DatabaseConnection]s + +use sea_orm::{DatabaseConnection, DbErr}; +use sea_orm_migration::MigratorTrait as _; + +/// A newtype wrapping a [DatabaseConnection] +pub struct Connection(DatabaseConnection); + +impl Connection { + /// Helper function for apllying database migrations while wrapping a + /// [DatabaseConnection] in a newtype + pub async fn try_from(database: DatabaseConnection) -> Result { + crate::migration::Migrator::up(&database, None).await?; + Ok(Self(database)) + } +} + +impl AsRef for Connection { + fn as_ref(&self) -> &DatabaseConnection { + &self.0 + } +} diff --git a/crates/db/src/entity/content/collections.rs b/crates/db/src/entity/content/collections.rs new file mode 100644 index 0000000..addbfae --- /dev/null +++ b/crates/db/src/entity/content/collections.rs @@ -0,0 +1,66 @@ +//! Collection entity + +use flix_model::id::{CollectionId, LibraryId}; + +use seamantic::model::path::PathBytes; + +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait, + EnumIter, PrimaryKeyTrait, Related, RelationDef, RelationTrait, +}; + +/// The database representation of a collection media folder +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_collections")] +pub struct Model { + /// The collection's ID + #[sea_orm(primary_key, auto_increment = false)] + pub id: CollectionId, + /// The collection's parent + pub parent: Option, + /// The collection's slug + pub slug: String, + /// The collection's library ID + pub library: LibraryId, + /// The collection's directory + pub directory: PathBytes, + /// The collection's poster path + pub relative_poster_path: Option, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation { + /// The parent collection of this collection + #[sea_orm( + belongs_to = "super::collections::Entity", + from = "Column::Parent", + to = "super::collections::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Parent, + /// The library this collection belongs to + #[sea_orm( + belongs_to = "super::libraries::Entity", + from = "Column::Library", + to = "super::libraries::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Library, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Parent.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Library.def() + } +} diff --git a/crates/db/src/entity/content/episodes.rs b/crates/db/src/entity/content/episodes.rs new file mode 100644 index 0000000..524b6e1 --- /dev/null +++ b/crates/db/src/entity/content/episodes.rs @@ -0,0 +1,58 @@ +//! Episode entity + +use flix_model::id::{LibraryId, ShowId}; + +use seamantic::model::path::PathBytes; + +use flix_model::numbers::{EpisodeNumber, SeasonNumber}; +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait, + EnumIter, PrimaryKeyTrait, Related, RelationDef, RelationTrait, +}; + +/// The database representation of a episode media folder +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_episodes")] +pub struct Model { + /// The episode's show's ID + #[sea_orm(primary_key, auto_increment = false)] + pub show: ShowId, + /// The episode's season's number + #[sea_orm(primary_key, auto_increment = false)] + pub season: SeasonNumber, + /// The episode's number + #[sea_orm(primary_key, auto_increment = false)] + pub episode: EpisodeNumber, + /// The episode's slug + pub slug: String, + /// The episode's library + pub library: LibraryId, + /// The episode's directory + pub directory: PathBytes, + /// The episode's media path + pub relative_media_path: PathBytes, + /// The episode's poster path + pub relative_poster_path: Option, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation { + /// The library this episode belongs to + #[sea_orm( + belongs_to = "super::libraries::Entity", + from = "Column::Library", + to = "super::libraries::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Library, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Library.def() + } +} diff --git a/crates/db/src/entity/content/libraries.rs b/crates/db/src/entity/content/libraries.rs new file mode 100644 index 0000000..9ac6808 --- /dev/null +++ b/crates/db/src/entity/content/libraries.rs @@ -0,0 +1,73 @@ +//! Library entity + +use flix_model::id::LibraryId; + +use seamantic::model::path::PathBytes; + +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait, + EnumIter, PrimaryKeyTrait, Related, RelationDef, RelationTrait, +}; + +/// The database representation of a library media folder +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_libraries")] +pub struct Model { + /// The library's ID + #[sea_orm(primary_key, auto_increment = false)] + pub id: LibraryId, + /// The library's directory + pub directory: PathBytes, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation { + /// All collections in this library + #[sea_orm(has_many = "super::collections::Entity")] + Collections, + #[sea_orm(has_many = "super::movies::Entity")] + /// All movies in this library + Movies, + #[sea_orm(has_many = "super::shows::Entity")] + /// All shows in this library + Shows, + #[sea_orm(has_many = "super::seasons::Entity")] + /// All seasons in this library + Seasons, + #[sea_orm(has_many = "super::episodes::Entity")] + /// All episodes in this library + Episodes, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Collections.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Movies.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Shows.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Seasons.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Episodes.def() + } +} diff --git a/crates/db/src/entity/content/mod.rs b/crates/db/src/entity/content/mod.rs new file mode 100644 index 0000000..f799238 --- /dev/null +++ b/crates/db/src/entity/content/mod.rs @@ -0,0 +1,340 @@ +//! This module contains entities for storing media file information + +pub mod libraries; + +pub mod collections; + +pub mod movies; + +pub mod episodes; +pub mod seasons; +pub mod shows; + +#[cfg(test)] +mod tests { + use std::path::Path; + + use flix_model::id::{CollectionId, LibraryId, MovieId, ShowId}; + + use sea_orm::ActiveModelTrait; + use sea_orm::ActiveValue::{NotSet, Set}; + use sea_orm::sqlx::error::ErrorKind; + use sea_orm_migration::MigratorTrait; + + use crate::migration::Migrator; + use crate::tests::new_memory_db; + + use super::super::tests::get_error_kind; + use super::super::tests::{ + make_flix_collection, make_flix_episode, make_flix_movie, make_flix_season, make_flix_show, + }; + use super::super::tests::{noneable, notsettable}; + + #[tokio::test] + async fn test_inserts() { + let db = new_memory_db().await; + Migrator::up(&db, None).await.expect("up"); + + // Libraries + macro_rules! assert_library { + ($db:expr, $id:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_library!(@insert, $db, $id $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.id, LibraryId::from_raw($id)); + assert_eq!(model.directory, Path::new(concat!("/L/", $id)).to_owned().into()); + }; + ($db:expr, $id:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_library!(@insert, $db, $id $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error); + }; + (@insert, $db:expr, $id:literal $(; $($skip:ident),+)?) => { + super::libraries::ActiveModel { + id: notsettable!(id, LibraryId::from_raw($id) $(, $($skip),+)?), + directory: notsettable!(directory, Path::new(concat!("/L/", $id)).to_owned().into() $(, $($skip),+)?), + }.insert($db).await + }; + } + assert_library!(&db, 1, Success); + assert_library!(&db, 1, UniqueViolation); + assert_library!(&db, 2, Success); + assert_library!(&db, 3, Success; id); + assert_library!(&db, 4, NotNullViolation; directory); + + // Collections + macro_rules! assert_collection { + ($db:expr, $id:literal, $pid:expr, $lid:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_collection!(@insert, $db, $id, $pid, $lid $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.id, CollectionId::from_raw($id)); + assert_eq!(model.parent, $pid); + assert_eq!(model.slug, concat!("C/", $id).to_string()); + assert_eq!(model.library, LibraryId::from_raw($lid)); + assert_eq!(model.directory, Path::new(concat!("/C/", $id)).to_owned().into()); + assert_eq!(model.relative_poster_path, noneable!(relative_poster_path, Path::new(concat!("C/Poster", $id)).to_owned().into() $(, $($skip),+)?)); + }; + ($db:expr, $id:literal, $pid:expr, $lid:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_collection!(@insert, $db, $id, $pid, $lid $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error); + }; + (@insert, $db:expr, $id:literal, $pid:expr, $lid:literal $(; $($skip:ident),+)?) => { + super::collections::ActiveModel { + id: notsettable!(id, CollectionId::from_raw($id) $(, $($skip),+)?), + parent: notsettable!(parent, $pid $(, $($skip),+)?), + slug: notsettable!(slug, concat!("C/", $id).to_string() $(, $($skip),+)?), + library: notsettable!(library, LibraryId::from_raw($lid) $(, $($skip),+)?), + directory: notsettable!(directory, Path::new(concat!("/C/", $id)).to_owned().into() $(, $($skip),+)?), + relative_poster_path: notsettable!(relative_poster_path, Some(Path::new(concat!("C/Poster", $id)).to_owned().into()) $(, $($skip),+)?), + }.insert($db).await + }; + } + assert_collection!(&db, 1, None, 0, ForeignKeyViolation); + assert_collection!( + &db, + 1, + Some(CollectionId::from_raw(0)), + 1, + ForeignKeyViolation + ); + assert_collection!(&db, 1, None, 1, ForeignKeyViolation); + make_flix_collection!(&db, 1); + make_flix_collection!(&db, 2); + make_flix_collection!(&db, 3); + make_flix_collection!(&db, 4); + make_flix_collection!(&db, 8); + + assert_collection!(&db, 1, None, 1, Success); + assert_collection!(&db, 1, None, 1, UniqueViolation); + assert_collection!(&db, 2, None, 1, Success); + assert_collection!(&db, 3, None, 1, Success; id); + assert_collection!(&db, 4, None, 1, Success; parent); + assert_collection!(&db, 5, None, 1, NotNullViolation; slug); + assert_collection!(&db, 6, None, 1, NotNullViolation; library); + assert_collection!(&db, 7, None, 1, NotNullViolation; directory); + assert_collection!(&db, 8, None, 1, Success; relative_poster_path); + + // Movies + macro_rules! assert_movie { + ($db:expr, $id:literal, $pid:expr, $lid:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_movie!(@insert, $db, $id, $pid, $lid $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.id, MovieId::from_raw($id)); + assert_eq!(model.parent, $pid); + assert_eq!(model.slug, concat!("M/", $id).to_string()); + assert_eq!(model.library, LibraryId::from_raw($lid)); + assert_eq!(model.directory, Path::new(concat!("/M/", $id)).to_owned().into()); + assert_eq!(model.relative_media_path, Path::new(concat!("M/Media", $id)).to_owned().into()); + assert_eq!(model.relative_poster_path, noneable!(relative_poster_path, Path::new(concat!("M/Poster", $id)).to_owned().into() $(, $($skip),+)?)); + }; + ($db:expr, $id:literal, $pid:expr, $lid:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_movie!(@insert, $db, $id, $pid, $lid $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error); + }; + (@insert, $db:expr, $id:literal, $pid:expr, $lid:literal $(; $($skip:ident),+)?) => { + super::movies::ActiveModel { + id: notsettable!(id, MovieId::from_raw($id) $(, $($skip),+)?), + parent: notsettable!(parent, $pid $(, $($skip),+)?), + slug: notsettable!(slug, concat!("M/", $id).to_string() $(, $($skip),+)?), + library: notsettable!(library, LibraryId::from_raw($lid) $(, $($skip),+)?), + directory: notsettable!(directory, Path::new(concat!("/M/", $id)).to_owned().into() $(, $($skip),+)?), + relative_media_path: notsettable!(relative_media_path, Path::new(concat!("M/Media", $id)).to_owned().into() $(, $($skip),+)?), + relative_poster_path: notsettable!(relative_poster_path, Some(Path::new(concat!("M/Poster", $id)).to_owned().into()) $(, $($skip),+)?), + }.insert($db).await + }; + } + assert_movie!(&db, 1, None, 0, ForeignKeyViolation); + assert_movie!( + &db, + 1, + Some(CollectionId::from_raw(0)), + 1, + ForeignKeyViolation + ); + assert_movie!(&db, 1, None, 1, ForeignKeyViolation); + make_flix_movie!(&db, 1); + make_flix_movie!(&db, 2); + make_flix_movie!(&db, 3); + make_flix_movie!(&db, 4); + make_flix_movie!(&db, 9); + + assert_movie!(&db, 1, None, 1, Success); + assert_movie!(&db, 1, None, 1, UniqueViolation); + assert_movie!(&db, 2, None, 1, Success); + assert_movie!(&db, 3, None, 1, Success; id); + assert_movie!(&db, 4, None, 1, Success; parent); + assert_movie!(&db, 5, None, 1, NotNullViolation; slug); + assert_movie!(&db, 6, None, 1, NotNullViolation; library); + assert_movie!(&db, 7, None, 1, NotNullViolation; directory); + assert_movie!(&db, 8, None, 1, NotNullViolation; relative_media_path); + assert_movie!(&db, 9, None, 1, Success; relative_poster_path); + + // Shows + macro_rules! assert_show { + ($db:expr, $id:literal, $pid:expr, $lid:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_show!(@insert, $db, $id, $pid, $lid $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.id, ShowId::from_raw($id)); + assert_eq!(model.parent, $pid); + assert_eq!(model.slug, concat!("S/", $id).to_string()); + assert_eq!(model.library, LibraryId::from_raw($lid)); + assert_eq!(model.directory, Path::new(concat!("/S/", $id)).to_owned().into()); + assert_eq!(model.relative_poster_path, noneable!(relative_poster_path, Path::new(concat!("S/Poster", $id)).to_owned().into() $(, $($skip),+)?)); + }; + ($db:expr, $id:literal, $pid:expr, $lid:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_show!(@insert, $db, $id, $pid, $lid $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error); + }; + (@insert, $db:expr, $id:literal, $pid:expr, $lid:literal $(; $($skip:ident),+)?) => { + super::shows::ActiveModel { + id: notsettable!(id, ShowId::from_raw($id) $(, $($skip),+)?), + parent: notsettable!(parent, $pid $(, $($skip),+)?), + slug: notsettable!(slug, concat!("S/", $id).to_string() $(, $($skip),+)?), + library: notsettable!(library, LibraryId::from_raw($lid) $(, $($skip),+)?), + directory: notsettable!(directory, Path::new(concat!("/S/", $id)).to_owned().into() $(, $($skip),+)?), + relative_poster_path: notsettable!(relative_poster_path, Some(Path::new(concat!("S/Poster", $id)).to_owned().into()) $(, $($skip),+)?), + }.insert($db).await + }; + } + assert_show!(&db, 1, None, 0, ForeignKeyViolation); + assert_show!( + &db, + 1, + Some(CollectionId::from_raw(0)), + 1, + ForeignKeyViolation + ); + assert_show!(&db, 1, None, 1, ForeignKeyViolation); + make_flix_show!(&db, 1); + make_flix_show!(&db, 2); + make_flix_show!(&db, 3); + make_flix_show!(&db, 4); + make_flix_show!(&db, 8); + + assert_show!(&db, 1, None, 1, Success); + assert_show!(&db, 1, None, 1, UniqueViolation); + assert_show!(&db, 2, None, 1, Success); + assert_show!(&db, 3, None, 1, Success; id); + assert_show!(&db, 4, None, 1, Success; parent); + assert_show!(&db, 5, None, 1, NotNullViolation; slug); + assert_show!(&db, 6, None, 1, NotNullViolation; library); + assert_show!(&db, 7, None, 1, NotNullViolation; directory); + assert_show!(&db, 8, None, 1, Success; relative_poster_path); + + // Seasons + macro_rules! assert_season { + ($db:expr, $id:literal, $season:literal, $lid:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_season!(@insert, $db, $id, $season, $lid $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.show, ShowId::from_raw($id)); + assert_eq!(model.season, $season); + assert_eq!(model.slug, concat!("S/S", $id).to_string()); + assert_eq!(model.library, LibraryId::from_raw($lid)); + assert_eq!(model.directory, Path::new(concat!("/S/S", $id)).to_owned().into()); + assert_eq!(model.relative_poster_path, noneable!(relative_poster_path, Path::new(concat!("S/S/Poster", $id)).to_owned().into() $(, $($skip),+)?)); + }; + ($db:expr, $id:literal, $season:literal, $lid:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_season!(@insert, $db, $id, $season, $lid $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error); + }; + (@insert, $db:expr, $id:literal, $season:literal, $lid:literal $(; $($skip:ident),+)?) => { + super::seasons::ActiveModel { + show: notsettable!(id, ShowId::from_raw($id) $(, $($skip),+)?), + season: notsettable!(season, $season $(, $($skip),+)?), + slug: notsettable!(slug, concat!("S/S", $id).to_string() $(, $($skip),+)?), + library: notsettable!(library, LibraryId::from_raw($lid) $(, $($skip),+)?), + directory: notsettable!(directory, Path::new(concat!("/S/S", $id)).to_owned().into() $(, $($skip),+)?), + relative_poster_path: notsettable!(relative_poster_path, Some(Path::new(concat!("S/S/Poster", $id)).to_owned().into()) $(, $($skip),+)?), + }.insert($db).await + }; + } + assert_season!(&db, 1, 1, 0, ForeignKeyViolation); + assert_season!(&db, 1, 1, 1, ForeignKeyViolation); + make_flix_season!(&db, 1, 1); + make_flix_season!(&db, 1, 2); + make_flix_season!(&db, 2, 1); + make_flix_season!(&db, 3, 1); + make_flix_season!(&db, 8, 1); + + assert_season!(&db, 1, 1, 1, Success); + assert_season!(&db, 1, 2, 1, Success); + assert_season!(&db, 1, 1, 1, UniqueViolation); + assert_season!(&db, 2, 1, 1, Success); + assert_season!(&db, 3, 1, 1, Success; show); + assert_season!(&db, 4, 1, 1, NotNullViolation; season); + assert_season!(&db, 5, 1, 1, NotNullViolation; slug); + assert_season!(&db, 6, 1, 1, NotNullViolation; library); + assert_season!(&db, 7, 1, 1, NotNullViolation; directory); + assert_season!(&db, 8, 1, 1, Success; relative_poster_path); + + // Episodes + macro_rules! assert_episode { + ($db:expr, $id:literal, $season:literal, $episode:literal, $lid:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_episode!(@insert, $db, $id, $season, $episode, $lid $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.show, ShowId::from_raw($id)); + assert_eq!(model.season, $season); + assert_eq!(model.episode, $episode); + assert_eq!(model.slug, concat!("S/S/E", $id).to_string()); + assert_eq!(model.library, LibraryId::from_raw($lid)); + assert_eq!(model.directory, Path::new(concat!("/S/S/E", $id)).to_owned().into()); + assert_eq!(model.relative_media_path, Path::new(concat!("E/Media", $id)).to_owned().into()); + assert_eq!(model.relative_poster_path, noneable!(relative_poster_path, Path::new(concat!("S/S/E/Poster", $id)).to_owned().into() $(, $($skip),+)?)); + }; + ($db:expr, $id:literal, $season:literal, $episode:literal, $lid:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_episode!(@insert, $db, $id, $season, $episode, $lid $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error); + }; + (@insert, $db:expr, $id:literal, $season:literal, $episode:literal, $lid:literal $(; $($skip:ident),+)?) => { + super::episodes::ActiveModel { + show: notsettable!(id, ShowId::from_raw($id) $(, $($skip),+)?), + season: notsettable!(season, $season $(, $($skip),+)?), + episode: notsettable!(episode, $episode $(, $($skip),+)?), + slug: notsettable!(slug, concat!("S/S/E", $id).to_string() $(, $($skip),+)?), + library: notsettable!(library, LibraryId::from_raw($lid) $(, $($skip),+)?), + directory: notsettable!(directory, Path::new(concat!("/S/S/E", $id)).to_owned().into() $(, $($skip),+)?), + relative_media_path: notsettable!(relative_media_path, Path::new(concat!("E/Media", $id)).to_owned().into() $(, $($skip),+)?), + relative_poster_path: notsettable!(relative_poster_path, Some(Path::new(concat!("S/S/E/Poster", $id)).to_owned().into()) $(, $($skip),+)?), + }.insert($db).await + }; + } + assert_episode!(&db, 1, 1, 1, 0, ForeignKeyViolation); + assert_episode!(&db, 1, 1, 1, 1, ForeignKeyViolation); + make_flix_episode!(&db, 1, 1, 1); + make_flix_episode!(&db, 1, 1, 2); + make_flix_episode!(&db, 2, 1, 1); + make_flix_episode!(&db, 3, 1, 1); + make_flix_show!(&db, 10); + make_flix_season!(&db, 10, 1); + make_flix_episode!(&db, 10, 1, 1); + + assert_episode!(&db, 1, 1, 1, 1, Success); + assert_episode!(&db, 1, 1, 2, 1, Success); + assert_episode!(&db, 1, 1, 1, 1, UniqueViolation); + assert_episode!(&db, 2, 1, 1, 1, Success); + assert_episode!(&db, 3, 1, 1, 1, Success; show); + assert_episode!(&db, 4, 1, 1, 1, NotNullViolation; season); + assert_episode!(&db, 5, 1, 1, 1, NotNullViolation; episode); + assert_episode!(&db, 6, 1, 1, 1, NotNullViolation; slug); + assert_episode!(&db, 7, 1, 1, 1, NotNullViolation; library); + assert_episode!(&db, 8, 1, 1, 1, NotNullViolation; directory); + assert_episode!(&db, 9, 1, 1, 1, NotNullViolation; relative_media_path); + assert_episode!(&db, 10, 1, 1, 1, Success; relative_poster_path); + } +} diff --git a/crates/db/src/entity/content/movies.rs b/crates/db/src/entity/content/movies.rs new file mode 100644 index 0000000..4dc57be --- /dev/null +++ b/crates/db/src/entity/content/movies.rs @@ -0,0 +1,68 @@ +//! Movie entity + +use flix_model::id::{CollectionId, LibraryId, MovieId}; + +use seamantic::model::path::PathBytes; + +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait, + EnumIter, PrimaryKeyTrait, Related, RelationDef, RelationTrait, +}; + +/// The database representation of a movie media folder +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_movies")] +pub struct Model { + /// The movie's ID + #[sea_orm(primary_key, auto_increment = false)] + pub id: MovieId, + /// The movie's parent + pub parent: Option, + /// The movie's slug + pub slug: String, + /// The movie's library + pub library: LibraryId, + /// The movie's directory + pub directory: PathBytes, + /// The movie's media path + pub relative_media_path: PathBytes, + /// The movie's poster path + pub relative_poster_path: Option, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation { + /// The parent collection of this collection + #[sea_orm( + belongs_to = "super::collections::Entity", + from = "Column::Parent", + to = "super::collections::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Parent, + /// The library this movie belongs to + #[sea_orm( + belongs_to = "super::libraries::Entity", + from = "Column::Library", + to = "super::libraries::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Library, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Parent.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Library.def() + } +} diff --git a/crates/db/src/entity/content/seasons.rs b/crates/db/src/entity/content/seasons.rs new file mode 100644 index 0000000..038b8db --- /dev/null +++ b/crates/db/src/entity/content/seasons.rs @@ -0,0 +1,53 @@ +//! Season entity + +use flix_model::id::{LibraryId, ShowId}; + +use seamantic::model::path::PathBytes; + +use flix_model::numbers::SeasonNumber; +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait, + EnumIter, PrimaryKeyTrait, Related, RelationDef, RelationTrait, +}; + +/// The database representation of a season media folder +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_seasons")] +pub struct Model { + /// The season's show's ID + #[sea_orm(primary_key, auto_increment = false)] + pub show: ShowId, + /// The season's number + #[sea_orm(primary_key, auto_increment = false)] + pub season: SeasonNumber, + /// The season's slug + pub slug: String, + /// The season's library + pub library: LibraryId, + /// The season's directory + pub directory: PathBytes, + /// The season's poster path + pub relative_poster_path: Option, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation { + /// The library this season belongs to + #[sea_orm( + belongs_to = "super::libraries::Entity", + from = "Column::Library", + to = "super::libraries::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Library, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Library.def() + } +} diff --git a/crates/db/src/entity/content/shows.rs b/crates/db/src/entity/content/shows.rs new file mode 100644 index 0000000..96ea4c3 --- /dev/null +++ b/crates/db/src/entity/content/shows.rs @@ -0,0 +1,66 @@ +//! Show entity + +use flix_model::id::{CollectionId, LibraryId, ShowId}; + +use seamantic::model::path::PathBytes; + +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait, + EnumIter, PrimaryKeyTrait, Related, RelationDef, RelationTrait, +}; + +/// The database representation of a show media folder +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_shows")] +pub struct Model { + /// The show's ID + #[sea_orm(primary_key, auto_increment = false)] + pub id: ShowId, + /// The show's parent + pub parent: Option, + /// The show's slug + pub slug: String, + /// The show's library + pub library: LibraryId, + /// The show's directory + pub directory: PathBytes, + /// The show's poster path + pub relative_poster_path: Option, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation { + /// The parent collection of this collection + #[sea_orm( + belongs_to = "super::collections::Entity", + from = "Column::Parent", + to = "super::collections::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Parent, + /// The library this show belongs to + #[sea_orm( + belongs_to = "super::libraries::Entity", + from = "Column::Library", + to = "super::libraries::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Library, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Parent.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Library.def() + } +} diff --git a/crates/db/src/entity/info/collections.rs b/crates/db/src/entity/info/collections.rs new file mode 100644 index 0000000..92b228a --- /dev/null +++ b/crates/db/src/entity/info/collections.rs @@ -0,0 +1,27 @@ +//! Collection entity + +use flix_model::id::CollectionId; + +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EnumIter, + PrimaryKeyTrait, +}; + +/// The database representation of a flix collection +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_info_collections")] +pub struct Model { + /// The collection's ID + #[sea_orm(primary_key, auto_increment = false)] + pub id: CollectionId, + /// The collection's title + pub title: String, + /// The collection's overview + pub overview: String, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/crates/db/src/entity/info/episodes.rs b/crates/db/src/entity/info/episodes.rs new file mode 100644 index 0000000..e01f148 --- /dev/null +++ b/crates/db/src/entity/info/episodes.rs @@ -0,0 +1,53 @@ +//! Episode entity + +use flix_model::id::ShowId; + +use chrono::NaiveDate; +use flix_model::numbers::{EpisodeNumber, SeasonNumber}; +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait, + EnumIter, PrimaryKeyTrait, Related, RelationDef, RelationTrait, +}; + +/// The database representation of a flix episode +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_info_episodes")] +pub struct Model { + /// The episode's show's ID + #[sea_orm(primary_key, auto_increment = false)] + pub show: ShowId, + /// The episode's season's number + #[sea_orm(primary_key, auto_increment = false)] + pub season: SeasonNumber, + /// The episode's number + #[sea_orm(primary_key, auto_increment = false)] + pub episode: EpisodeNumber, + /// The episode's title + pub title: String, + /// The episode's overview + pub overview: String, + /// The episode's air date + pub date: NaiveDate, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation { + /// The show this season belongs to + #[sea_orm( + belongs_to = "super::shows::Entity", + from = "Column::Show", + to = "super::shows::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Show, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Show.def() + } +} diff --git a/crates/db/src/entity/info/mod.rs b/crates/db/src/entity/info/mod.rs new file mode 100644 index 0000000..96c2e6f --- /dev/null +++ b/crates/db/src/entity/info/mod.rs @@ -0,0 +1,230 @@ +//! This module contains entities for storing media information such as +//! titles and overviews + +pub mod collections; + +pub mod movies; + +pub mod episodes; +pub mod seasons; +pub mod shows; + +#[cfg(test)] +mod tests { + use flix_model::id::{CollectionId, MovieId, ShowId}; + + use chrono::NaiveDate; + use sea_orm::ActiveModelTrait; + use sea_orm::ActiveValue::{NotSet, Set}; + use sea_orm::sqlx::error::ErrorKind; + use sea_orm_migration::MigratorTrait; + + use crate::migration::Migrator; + use crate::tests::new_memory_db; + + use super::super::tests::get_error_kind; + use super::super::tests::notsettable; + + #[tokio::test] + async fn test_inserts() { + let db = new_memory_db().await; + Migrator::up(&db, None).await.expect("up"); + + // Collections + macro_rules! assert_collection { + ($db:expr, $id:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_collection!(@insert, $db, $id $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.id, CollectionId::from_raw($id)); + assert_eq!(model.title, concat!("C", $id)); + assert_eq!(model.overview, concat!("Collection", " ", $id)); + }; + ($db:expr, $id:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_collection!(@insert, $db, $id $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error); + }; + (@insert, $db:expr, $id:literal $(; $($skip:ident),+)?) => { + super::collections::ActiveModel { + id: notsettable!(id, CollectionId::from_raw($id) $(, $($skip),+)?), + title: notsettable!(title, concat!("C", $id).to_string() $(, $($skip),+)?), + overview: notsettable!(overview, concat!("Collection", " ", $id).to_string() $(, $($skip),+)?), + }.insert($db).await + }; + } + assert_collection!(&db, 1, Success); + assert_collection!(&db, 1, UniqueViolation); + assert_collection!(&db, 2, Success); + assert_collection!(&db, 3, Success; id); + assert_collection!(&db, 4, NotNullViolation; title); + assert_collection!(&db, 5, NotNullViolation; overview); + + // Movies + macro_rules! assert_movie { + ($db:expr, $id:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_movie!(@insert, $db, $id $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.id, MovieId::from_raw($id)); + assert_eq!(model.title, concat!("M", $id)); + assert_eq!(model.tagline, concat!("Watch Movie", " ", $id)); + assert_eq!(model.overview, concat!("Movie", " ", $id)); + assert_eq!(model.date, NaiveDate::from_yo_opt(2000, $id).expect("NaiveDate::from_yo_opt")); + }; + ($db:expr, $id:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_movie!(@insert, $db, $id $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error); + }; + (@insert, $db:expr, $id:literal $(; $($skip:ident),+)?) => { + super::movies::ActiveModel { + id: notsettable!(id, MovieId::from_raw($id) $(, $($skip),+)?), + title: notsettable!(title, concat!("M", $id).to_string() $(, $($skip),+)?), + tagline: notsettable!(tagline, concat!("Watch Movie", " ", $id).to_string() $(, $($skip),+)?), + overview: notsettable!(overview, concat!("Movie", " ", $id).to_string() $(, $($skip),+)?), + date: notsettable!(date, NaiveDate::from_yo_opt(2000, $id).expect("NaiveDate::from_yo_opt") $(, $($skip),+)?), + }.insert($db).await + }; + } + assert_movie!(&db, 1, Success); + assert_movie!(&db, 1, UniqueViolation); + assert_movie!(&db, 2, Success); + assert_movie!(&db, 3, Success; id); + assert_movie!(&db, 4, NotNullViolation; title); + assert_movie!(&db, 5, NotNullViolation; tagline); + assert_movie!(&db, 6, NotNullViolation; overview); + assert_movie!(&db, 7, NotNullViolation; date); + + // Shows + macro_rules! assert_show { + ($db:expr, $id:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_show!(@insert, $db, $id $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.id, ShowId::from_raw($id)); + assert_eq!(model.title, concat!("S", $id)); + assert_eq!(model.tagline, concat!("Watch Show", " ", $id)); + assert_eq!(model.overview, concat!("Show", " ", $id)); + assert_eq!(model.date, NaiveDate::from_yo_opt(2000, $id).expect("NaiveDate::from_yo_opt")); + }; + ($db:expr, $id:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_show!(@insert, $db, $id $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!( + get_error_kind(model).expect("get_error_kind"), + ErrorKind::$error + ); + }; + (@insert, $db:expr, $id:literal $(; $($skip:ident),+)?) => { + super::shows::ActiveModel { + id: notsettable!(id, ShowId::from_raw($id) $(, $($skip),+)?), + title: notsettable!(title, concat!("S", $id).to_string() $(, $($skip),+)?), + tagline: notsettable!(tagline, concat!("Watch Show", " ", $id).to_string() $(, $($skip),+)?), + overview: notsettable!(overview, concat!("Show", " ", $id).to_string() $(, $($skip),+)?), + date: notsettable!(date, NaiveDate::from_yo_opt(2000, $id).expect("NaiveDate::from_yo_opt") $(, $($skip),+)?), + }.insert($db).await + }; + } + assert_show!(&db, 1, Success); + assert_show!(&db, 1, UniqueViolation); + assert_show!(&db, 2, Success); + assert_show!(&db, 3, Success; id); + assert_show!(&db, 4, NotNullViolation; title); + assert_show!(&db, 5, NotNullViolation; tagline); + assert_show!(&db, 6, NotNullViolation; overview); + assert_show!(&db, 7, NotNullViolation; date); + + // Seasons + macro_rules! assert_season { + ($db:expr, $show:literal, $season:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_season!(@insert, $db, $show, $season $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.show, ShowId::from_raw($show)); + assert_eq!(model.season, $season); + assert_eq!(model.title, concat!("S", $show, "S", $season)); + assert_eq!(model.overview, concat!("Show", " ", $show, " ", "Season", " ", $season)); + assert_eq!(model.date, NaiveDate::from_yo_opt(2000, $show + $season).expect("NaiveDate::from_yo_opt")); + }; + ($db:expr, $show:literal, $season:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_season!(@insert, $db, $show, $season $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!( + get_error_kind(model).expect("get_error_kind"), + ErrorKind::$error + ); + }; + (@insert, $db:expr, $show:literal, $season:literal $(; $($skip:ident),+)?) => { + super::seasons::ActiveModel { + show: notsettable!(show, ShowId::from_raw($show) $(, $($skip),+)?), + season: notsettable!(season, $season $(, $($skip),+)?), + title: notsettable!(title, concat!("S", $show, "S", $season).to_string() $(, $($skip),+)?), + overview: notsettable!(overview, concat!("Show", " ", $show, " ", "Season", " ", $season).to_string() $(, $($skip),+)?), + date: notsettable!(date, NaiveDate::from_yo_opt(2000, $show + $season).expect("NaiveDate::from_yo_opt") $(, $($skip),+)?), + }.insert($db).await + }; + } + assert_season!(&db, 1, 1, Success); + assert_season!(&db, 1, 1, UniqueViolation); + assert_season!(&db, 2, 1, Success); + assert_season!(&db, 0, 1, ForeignKeyViolation); + assert_season!(&db, 1, 2, Success); + assert_season!(&db, 1, 3, NotNullViolation; show); + assert_season!(&db, 1, 4, NotNullViolation; season); + assert_season!(&db, 1, 5, NotNullViolation; title); + assert_season!(&db, 1, 6, NotNullViolation; overview); + assert_season!(&db, 1, 7, NotNullViolation; date); + + // Episodes + macro_rules! assert_episode { + ($db:expr, $show:literal, $season:literal, $episode:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_episode!(@insert, $db, $show, $season, $episode $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.show, ShowId::from_raw($show)); + assert_eq!(model.season, $season); + assert_eq!(model.episode, $episode); + assert_eq!(model.title, concat!("S", $show, "S", $season, "E", $episode)); + assert_eq!(model.overview, concat!("Show", " ", $show, " ", "Season", " ", $season, " ", "Episode", " ", $episode)); + assert_eq!(model.date, NaiveDate::from_yo_opt(2000, $show + $season + $episode).expect("NaiveDate::from_yo_opt")); + }; + ($db:expr, $show:literal, $season:literal, $episode:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_episode!(@insert, $db, $show, $season, $episode $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!( + get_error_kind(model).expect("get_error_kind"), + ErrorKind::$error + ); + }; + (@insert, $db:expr, $show:literal, $season:literal, $episode:literal $(; $($skip:ident),+)?) => { + super::episodes::ActiveModel { + show: notsettable!(show, ShowId::from_raw($show) $(, $($skip),+)?), + season: notsettable!(season, $season $(, $($skip),+)?), + episode: notsettable!(episode, $episode $(, $($skip),+)?), + title: notsettable!(title, concat!("S", $show, "S", $season, "E", $episode).to_string() $(, $($skip),+)?), + overview: notsettable!(overview, concat!("Show", " ", $show, " ", "Season", " ", $season, " ", "Episode", " ", $episode).to_string() $(, $($skip),+)?), + date: notsettable!(date, NaiveDate::from_yo_opt(2000, $show + $season + $episode).expect("NaiveDate::from_yo_opt") $(, $($skip),+)?), + }.insert($db).await + }; + } + assert_episode!(&db, 1, 1, 1, Success); + assert_episode!(&db, 1, 1, 1, UniqueViolation); + assert_episode!(&db, 1, 2, 1, Success); + assert_episode!(&db, 2, 1, 1, Success); + assert_episode!(&db, 1, 0, 1, ForeignKeyViolation); + assert_episode!(&db, 0, 1, 1, ForeignKeyViolation); + assert_episode!(&db, 1, 1, 2, Success); + assert_episode!(&db, 1, 1, 3, NotNullViolation; show); + assert_episode!(&db, 1, 1, 4, NotNullViolation; season); + assert_episode!(&db, 1, 1, 4, NotNullViolation; episode); + assert_episode!(&db, 1, 1, 5, NotNullViolation; title); + assert_episode!(&db, 1, 1, 6, NotNullViolation; overview); + assert_episode!(&db, 1, 1, 7, NotNullViolation; date); + } +} diff --git a/crates/db/src/entity/info/movies.rs b/crates/db/src/entity/info/movies.rs new file mode 100644 index 0000000..06b4ca3 --- /dev/null +++ b/crates/db/src/entity/info/movies.rs @@ -0,0 +1,32 @@ +//! Movie entity + +use flix_model::id::MovieId; + +use chrono::NaiveDate; +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EnumIter, + PrimaryKeyTrait, +}; + +/// The database representation of a flix movie +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_info_movies")] +pub struct Model { + /// The movie's ID + #[sea_orm(primary_key, auto_increment = false)] + pub id: MovieId, + /// The movie's title + pub title: String, + /// The movie's tagline + pub tagline: String, + /// The movie's overview + pub overview: String, + /// The movie's release date + pub date: NaiveDate, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/crates/db/src/entity/info/seasons.rs b/crates/db/src/entity/info/seasons.rs new file mode 100644 index 0000000..904f9c9 --- /dev/null +++ b/crates/db/src/entity/info/seasons.rs @@ -0,0 +1,50 @@ +//! Season entity + +use flix_model::id::ShowId; + +use chrono::NaiveDate; +use flix_model::numbers::SeasonNumber; +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait, + EnumIter, PrimaryKeyTrait, Related, RelationDef, RelationTrait, +}; + +/// The database representation of a flix season +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_info_seasons")] +pub struct Model { + /// The season's show's ID + #[sea_orm(primary_key, auto_increment = false)] + pub show: ShowId, + /// The season's number + #[sea_orm(primary_key, auto_increment = false)] + pub season: SeasonNumber, + /// The season's title + pub title: String, + /// The season's overview + pub overview: String, + /// The season's air date + pub date: NaiveDate, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation { + /// The show this season belongs to + #[sea_orm( + belongs_to = "super::shows::Entity", + from = "Column::Show", + to = "super::shows::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Show, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Show.def() + } +} diff --git a/crates/db/src/entity/info/shows.rs b/crates/db/src/entity/info/shows.rs new file mode 100644 index 0000000..a3ff0c9 --- /dev/null +++ b/crates/db/src/entity/info/shows.rs @@ -0,0 +1,51 @@ +//! Show entity + +use flix_model::id::ShowId; + +use chrono::NaiveDate; +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait, + EnumIter, PrimaryKeyTrait, Related, RelationDef, RelationTrait, +}; + +/// The database representation of a flix show +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_info_shows")] +pub struct Model { + /// The show's ID + #[sea_orm(primary_key, auto_increment = false)] + pub id: ShowId, + /// The show's title + pub title: String, + /// The show's tagline + pub tagline: String, + /// The show's overview + pub overview: String, + /// The show's air date + pub date: NaiveDate, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation { + /// The seasons that are part of this show + #[sea_orm(has_many = "super::seasons::Entity")] + Seasons, + /// The episodes that are part of this show + #[sea_orm(has_many = "super::episodes::Entity")] + Episodes, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Seasons.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Episodes.def() + } +} diff --git a/crates/db/src/entity/mod.rs b/crates/db/src/entity/mod.rs new file mode 100644 index 0000000..d24df5a --- /dev/null +++ b/crates/db/src/entity/mod.rs @@ -0,0 +1,265 @@ +//! Entity structs for interacting with the database + +pub mod content; +pub mod info; +#[cfg(feature = "tmdb")] +pub mod tmdb; +pub mod watched; + +#[cfg(test)] +mod tests { + pub use sea_orm::DbErr; + use sea_orm::sqlx::error::ErrorKind; + + #[derive(Debug)] + pub enum ErrorKindError { + NotRuntimeError, + NotSqlxError, + NotDatabaseError, + } + + pub fn get_error_kind(error: DbErr) -> Result { + let runtime_err = match error { + DbErr::Conn(runtime_err) => runtime_err, + DbErr::Exec(runtime_err) => runtime_err, + DbErr::Query(runtime_err) => runtime_err, + _ => return Err(ErrorKindError::NotRuntimeError), + }; + + let sqlx_err = match runtime_err { + sea_orm::RuntimeErr::SqlxError(sqlx_err) => sqlx_err, + _ => return Err(ErrorKindError::NotSqlxError), + }; + + let database_err = match sqlx_err.as_ref() { + sea_orm::SqlxError::Database(database_err) => database_err, + _ => return Err(ErrorKindError::NotDatabaseError), + }; + + Ok(database_err.kind()) + } + + /// Helper macro for writing tests for `ActiveModel` structs where + /// toggling [sea_orm::ActiveValue] is needed + macro_rules! notsettable { + ($field:ident, $value:expr $(, $($skip:ident),+)?) => { + if notsettable!(@skip, $field $(, $($skip),+)?) { + NotSet + } else { + Set($value) + } + }; + (@skip, $field:ident $(, $skip:ident),*) => { + false $(|| stringify!($field) == stringify!($skip))* + }; + } + pub(super) use notsettable; + + /// Helper macro for writing tests for `ActiveModel` structs where + /// toggling [sea_orm::ActiveValue] is needed + macro_rules! noneable { + ($field:ident, $value:expr $(, $($skip:ident),+)?) => { + if noneable!(@skip, $field $(, $($skip),+)?) { + None + } else { + Some($value) + } + }; + (@skip, $field:ident $(, $skip:ident),*) => { + false $(|| stringify!($field) == stringify!($skip))* + }; + } + pub(super) use noneable; + + /// Helper macro for creating a flix library + macro_rules! have_library { + ($db:expr, $id:literal) => { + $crate::entity::content::libraries::ActiveModel { + id: Set(::flix_model::id::LibraryId::from_raw($id)), + directory: Set(::std::path::PathBuf::new().into()), + } + .insert($db) + .await + .expect("insert"); + }; + } + pub(super) use have_library; + + /// Helper macro for creating a flix collection + macro_rules! make_flix_collection { + ($db:expr, $id:literal) => { + $crate::entity::info::collections::ActiveModel { + id: Set(::flix_model::id::CollectionId::from_raw($id)), + title: Set(::std::string::String::new()), + overview: Set(::std::string::String::new()), + } + .insert($db) + .await + .expect("insert"); + }; + } + pub(super) use make_flix_collection; + macro_rules! have_collection { + ($db:expr, $lid:literal, $id:literal, $pid:expr) => { + $crate::entity::tests::make_flix_collection!($db, $id); + $crate::entity::content::collections::ActiveModel { + id: Set(::flix_model::id::CollectionId::from_raw($id)), + parent: Set($pid.map(::flix_model::id::CollectionId::from_raw)), + slug: Set(::std::string::String::new()), + library: Set(::flix_model::id::LibraryId::from_raw($lid)), + directory: Set(::std::path::PathBuf::new().into()), + relative_poster_path: Set(None), + } + .insert($db) + .await + .expect("insert"); + }; + } + pub(super) use have_collection; + + /// Helper macro for creating a flix movie + macro_rules! make_flix_movie { + ($db:expr, $id:literal) => { + $crate::entity::info::movies::ActiveModel { + id: Set(::flix_model::id::MovieId::from_raw($id)), + title: Set(::std::string::String::new()), + tagline: Set(::std::string::String::new()), + overview: Set(::std::string::String::new()), + date: Set( + ::chrono::NaiveDate::from_yo_opt(2000, $id).expect("NaiveDate::from_yo_opt") + ), + } + .insert($db) + .await + .expect("insert"); + }; + } + pub(super) use make_flix_movie; + macro_rules! have_movie { + ($db:expr, $lid:literal, $id:literal, $pid:expr) => { + $crate::entity::tests::make_flix_movie!($db, $id); + $crate::entity::content::movies::ActiveModel { + id: Set(::flix_model::id::MovieId::from_raw($id)), + parent: Set($pid.map(::flix_model::id::CollectionId::from_raw)), + slug: Set(::std::string::String::new()), + library: Set(::flix_model::id::LibraryId::from_raw($lid)), + directory: Set(::std::path::PathBuf::new().into()), + relative_media_path: Set(::std::path::PathBuf::new().into()), + relative_poster_path: Set(None), + } + .insert($db) + .await + .expect("insert"); + }; + } + pub(super) use have_movie; + + /// Helper macro for creating a flix show + macro_rules! make_flix_show { + ($db:expr, $id:literal) => { + $crate::entity::info::shows::ActiveModel { + id: Set(::flix_model::id::ShowId::from_raw($id)), + title: Set(::std::string::String::new()), + tagline: Set(::std::string::String::new()), + overview: Set(::std::string::String::new()), + date: Set( + ::chrono::NaiveDate::from_yo_opt(2000, $id).expect("NaiveDate::from_yo_opt") + ), + } + .insert($db) + .await + .expect("insert"); + }; + } + pub(super) use make_flix_show; + macro_rules! have_show { + ($db:expr, $lid:literal, $id:literal, $pid:expr) => { + $crate::entity::tests::make_flix_show!($db, $id); + $crate::entity::content::shows::ActiveModel { + id: Set(::flix_model::id::ShowId::from_raw($id)), + parent: Set($pid.map(::flix_model::id::CollectionId::from_raw)), + slug: Set(::std::string::String::new()), + library: Set(::flix_model::id::LibraryId::from_raw($lid)), + directory: Set(::std::path::PathBuf::new().into()), + relative_poster_path: Set(None), + } + .insert($db) + .await + .expect("insert"); + }; + } + pub(super) use have_show; + + /// Helper macro for creating a flix season + macro_rules! make_flix_season { + ($db:expr, $show:literal, $season:literal) => { + $crate::entity::info::seasons::ActiveModel { + show: Set(::flix_model::id::ShowId::from_raw($show)), + season: Set($season), + title: Set(::std::string::String::new()), + overview: Set(::std::string::String::new()), + date: Set(::chrono::NaiveDate::from_yo_opt(2000, $show + $season) + .expect("NaiveDate::from_yo_opt")), + } + .insert($db) + .await + .expect("insert"); + }; + } + pub(super) use make_flix_season; + macro_rules! have_season { + ($db:expr, $lid:literal, $show:literal, $season:literal) => { + $crate::entity::tests::make_flix_season!($db, $show, $season); + $crate::entity::content::seasons::ActiveModel { + show: Set(::flix_model::id::ShowId::from_raw($show)), + season: Set($season), + slug: Set(::std::string::String::new()), + library: Set(::flix_model::id::LibraryId::from_raw($lid)), + directory: Set(::std::path::PathBuf::new().into()), + relative_poster_path: Set(None), + } + .insert($db) + .await + .expect("insert"); + }; + } + pub(super) use have_season; + + /// Helper macro for creating a flix episode + macro_rules! make_flix_episode { + ($db:expr, $show:literal, $season:literal, $episode:literal) => { + $crate::entity::info::episodes::ActiveModel { + show: Set(::flix_model::id::ShowId::from_raw($show)), + season: Set($season), + episode: Set($episode), + title: Set(::std::string::String::new()), + overview: Set(::std::string::String::new()), + date: Set(::chrono::NaiveDate::from_yo_opt(2000, $show + $season) + .expect("NaiveDate::from_yo_opt")), + } + .insert($db) + .await + .expect("insert"); + }; + } + pub(super) use make_flix_episode; + macro_rules! have_episode { + ($db:expr, $lid:literal, $show:literal, $season:literal, $episode:literal) => { + $crate::entity::tests::make_flix_episode!($db, $show, $season, $episode); + $crate::entity::content::episodes::ActiveModel { + show: Set(::flix_model::id::ShowId::from_raw($show)), + season: Set($season), + episode: Set($episode), + slug: Set(::std::string::String::new()), + library: Set(::flix_model::id::LibraryId::from_raw($lid)), + directory: Set(::std::path::PathBuf::new().into()), + relative_media_path: Set(::std::path::PathBuf::new().into()), + relative_poster_path: Set(None), + } + .insert($db) + .await + .expect("insert"); + }; + } + pub(super) use have_episode; +} diff --git a/crates/db/src/entity/tmdb/collections.rs b/crates/db/src/entity/tmdb/collections.rs new file mode 100644 index 0000000..ebe15d3 --- /dev/null +++ b/crates/db/src/entity/tmdb/collections.rs @@ -0,0 +1,41 @@ +//! Collection entity + +use flix_model::id::CollectionId as FlixId; +use flix_tmdb::model::id::CollectionId; + +use chrono::NaiveDate; +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait, + EnumIter, PrimaryKeyTrait, Related, RelationDef, RelationTrait, +}; + +/// The database representation of a tmdb collection +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_tmdb_collections")] +pub struct Model { + /// The collection's TMDB ID + #[sea_orm(primary_key, auto_increment = false)] + pub tmdb_id: CollectionId, + /// The collection's ID + pub flix_id: FlixId, + /// The date of the last update + pub last_update: NaiveDate, + /// The number of movies in the collection + pub movie_count: u16, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation { + /// The movies that are part of this collection + #[sea_orm(has_many = "super::movies::Entity")] + Movies, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Movies.def() + } +} diff --git a/crates/db/src/entity/tmdb/episodes.rs b/crates/db/src/entity/tmdb/episodes.rs new file mode 100644 index 0000000..bbecec2 --- /dev/null +++ b/crates/db/src/entity/tmdb/episodes.rs @@ -0,0 +1,43 @@ +//! Season entity + +use flix_model::id::ShowId as FlixId; +use flix_model::numbers::{EpisodeNumber, SeasonNumber}; +use flix_tmdb::model::id::ShowId; +use seamantic::model::duration::Seconds; + +use chrono::NaiveDate; +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EnumIter, + PrimaryKeyTrait, +}; + +/// The database representation of a tmdb episode +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_tmdb_episodes")] +pub struct Model { + /// The episode's show's TMDB ID + #[sea_orm(primary_key, auto_increment = false)] + pub tmdb_show: ShowId, + /// The episode's season's TMDB season number + #[sea_orm(primary_key, auto_increment = false)] + pub tmdb_season: SeasonNumber, + /// The episode's TMDB episode number + #[sea_orm(primary_key, auto_increment = false)] + pub tmdb_episode: EpisodeNumber, + /// The episode's show's ID + pub flix_show: FlixId, + /// The episode's season's number + pub flix_season: SeasonNumber, + /// The episode's number + pub flix_episode: EpisodeNumber, + /// The date of the last update + pub last_update: NaiveDate, + /// The episode's runtime in seconds + pub runtime: Seconds, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/crates/db/src/entity/tmdb/mod.rs b/crates/db/src/entity/tmdb/mod.rs new file mode 100644 index 0000000..3a91d95 --- /dev/null +++ b/crates/db/src/entity/tmdb/mod.rs @@ -0,0 +1,278 @@ +//! This module contains entities for storing dynamic data from TMDB + +pub mod collections; + +pub mod movies; + +pub mod episodes; +pub mod seasons; +pub mod shows; + +#[cfg(test)] +mod tests { + use core::time::Duration; + + use flix_model::id::{CollectionId, MovieId, ShowId}; + use flix_tmdb::model::id::{ + CollectionId as TmdbCollectionId, MovieId as TmdbMovieId, ShowId as TmdbShowId, + }; + + use chrono::NaiveDate; + use sea_orm::ActiveModelTrait; + use sea_orm::ActiveValue::{NotSet, Set}; + use sea_orm::sqlx::error::ErrorKind; + use sea_orm_migration::MigratorTrait; + + use crate::migration::Migrator; + use crate::tests::new_memory_db; + + use super::super::tests::get_error_kind; + use super::super::tests::notsettable; + use super::super::tests::{ + make_flix_collection, make_flix_episode, make_flix_movie, make_flix_season, make_flix_show, + }; + + #[tokio::test] + async fn test_inserts() { + let db = new_memory_db().await; + Migrator::up(&db, None).await.expect("up"); + + // Collections + macro_rules! assert_collection { + ($db:expr, $id:literal, $tid:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_collection!(@insert, $db, $id, $tid $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.tmdb_id, TmdbCollectionId::from_raw($tid)); + assert_eq!(model.flix_id, CollectionId::from_raw($id)); + assert_eq!(model.last_update, NaiveDate::from_yo_opt(2000, $tid).expect("NaiveDate::from_yo_opt")); + assert_eq!(model.movie_count, $id); + }; + ($db:expr, $id:literal, $tid:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_collection!(@insert, $db, $id, $tid $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error); + }; + (@insert, $db:expr, $id:literal, $tid:literal $(; $($skip:ident),+)?) => { + super::collections::ActiveModel { + tmdb_id: notsettable!(tmdb_id, TmdbCollectionId::from_raw($tid) $(, $($skip),+)?), + flix_id: notsettable!(flix_id, CollectionId::from_raw($id) $(, $($skip),+)?), + last_update: notsettable!(last_update, NaiveDate::from_yo_opt(2000, $tid).expect("NaiveDate::from_yo_opt") $(, $($skip),+)?), + movie_count: notsettable!(movie_count, $id $(, $($skip),+)?), + }.insert($db).await + }; + } + assert_collection!(&db, 1, 1, ForeignKeyViolation); + make_flix_collection!(&db, 1); + make_flix_collection!(&db, 2); + make_flix_collection!(&db, 3); + + assert_collection!(&db, 1, 1, Success); + assert_collection!(&db, 1, 1, UniqueViolation); + assert_collection!(&db, 1, 2, UniqueViolation); + assert_collection!(&db, 2, 1, UniqueViolation); + assert_collection!(&db, 2, 2, Success); + assert_collection!(&db, 3, 3, Success; tmdb_id); + assert_collection!(&db, 4, 4, NotNullViolation; flix_id); + assert_collection!(&db, 5, 5, NotNullViolation; last_update); + assert_collection!(&db, 6, 6, NotNullViolation; movie_count); + + // Movies + macro_rules! assert_movie { + ($db:expr, $id:literal, $tid:literal, $cid:expr, Success $(; $($skip:ident),+)?) => { + let model = assert_movie!(@insert, $db, $id, $tid, $cid $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.tmdb_id, TmdbMovieId::from_raw($tid)); + assert_eq!(model.flix_id, MovieId::from_raw($id)); + assert_eq!(model.last_update, NaiveDate::from_yo_opt(2000, $tid).expect("NaiveDate::from_yo_opt")); + assert_eq!(model.runtime, Duration::from_secs($tid).into()); + assert_eq!(model.collection, $cid); + }; + ($db:expr, $id:literal, $tid:literal, $cid:expr, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_movie!(@insert, $db, $id, $tid, $cid $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error); + }; + (@insert, $db:expr, $id:literal, $tid:literal, $cid:expr $(; $($skip:ident),+)?) => { + super::movies::ActiveModel { + tmdb_id: notsettable!(tmdb_id, TmdbMovieId::from_raw($tid) $(, $($skip),+)?), + flix_id: notsettable!(flix_id, MovieId::from_raw($id) $(, $($skip),+)?), + last_update: notsettable!(last_update, NaiveDate::from_yo_opt(2000, $tid).expect("NaiveDate::from_yo_opt") $(, $($skip),+)?), + runtime: notsettable!(runtime, Duration::from_secs($tid).into() $(, $($skip),+)?), + collection: notsettable!(collection, $cid $(, $($skip),+)?), + }.insert($db).await + }; + } + assert_movie!( + &db, + 1, + 1, + Some(TmdbCollectionId::from_raw(1)), + ForeignKeyViolation + ); + make_flix_movie!(&db, 1); + make_flix_movie!(&db, 2); + make_flix_movie!(&db, 3); + + assert_movie!(&db, 1, 1, Some(TmdbCollectionId::from_raw(1)), Success); + assert_movie!(&db, 1, 1, None, UniqueViolation); + assert_movie!(&db, 1, 2, None, UniqueViolation); + assert_movie!(&db, 2, 1, None, UniqueViolation); + assert_movie!(&db, 2, 2, Some(TmdbCollectionId::from_raw(1)), Success); + assert_movie!(&db, 3, 3, None, Success; tmdb_id); + assert_movie!(&db, 4, 4, None, NotNullViolation; flix_id); + assert_movie!(&db, 5, 5, None, NotNullViolation; last_update); + assert_movie!(&db, 6, 6, None, NotNullViolation; runtime); + assert_movie!(&db, 7, 7, None, ForeignKeyViolation; collection); // Must be `Set(None)` + + // Shows + macro_rules! assert_show { + ($db:expr, $id:literal, $tid:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_show!(@insert, $db, $id, $tid $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.tmdb_id, TmdbShowId::from_raw($tid)); + assert_eq!(model.flix_id, ShowId::from_raw($id)); + assert_eq!(model.last_update, NaiveDate::from_yo_opt(2000, $tid).expect("NaiveDate::from_yo_opt")); + assert_eq!(model.number_of_seasons, $id); + }; + ($db:expr, $id:literal, $tid:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_show!(@insert, $db, $id, $tid $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!( + get_error_kind(model).expect("get_error_kind"), + ErrorKind::$error + ); + }; + (@insert, $db:expr, $id:literal, $tid:literal $(; $($skip:ident),+)?) => { + super::shows::ActiveModel { + tmdb_id: notsettable!(tmdb_id, TmdbShowId::from_raw($tid) $(, $($skip),+)?), + flix_id: notsettable!(flix_id, ShowId::from_raw($id) $(, $($skip),+)?), + last_update: notsettable!(last_update, NaiveDate::from_yo_opt(2000, $tid).expect("NaiveDate::from_yo_opt") $(, $($skip),+)?), + number_of_seasons: notsettable!(number_of_seasons, $id $(, $($skip),+)?), + }.insert($db).await + }; + } + assert_show!(&db, 1, 1, ForeignKeyViolation); + make_flix_show!(&db, 1); + make_flix_show!(&db, 2); + make_flix_show!(&db, 3); + + assert_show!(&db, 1, 1, Success); + assert_show!(&db, 1, 1, UniqueViolation); + assert_show!(&db, 1, 2, UniqueViolation); + assert_show!(&db, 2, 1, UniqueViolation); + assert_show!(&db, 2, 2, Success); + assert_show!(&db, 3, 3, Success; tmdb_id); + assert_show!(&db, 4, 4, NotNullViolation; flix_id); + assert_show!(&db, 5, 5, NotNullViolation; last_update); + assert_show!(&db, 6, 6, NotNullViolation; number_of_seasons); + + // Seasons + macro_rules! assert_season { + ($db:expr, $show:literal, $season:literal, $tshow:literal, $tseason:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_season!(@insert, $db, $show, $season, $tshow, $tseason $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.tmdb_show, TmdbShowId::from_raw($tshow)); + assert_eq!(model.tmdb_season, $tseason); + assert_eq!(model.flix_show, ShowId::from_raw($show)); + assert_eq!(model.flix_season, $season); + assert_eq!(model.last_update, NaiveDate::from_yo_opt(2000, $tshow).expect("NaiveDate::from_yo_opt")); + }; + ($db:expr, $show:literal, $season:literal, $tshow:literal, $tseason:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_season!(@insert, $db, $show, $season, $tshow, $tseason $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!( + get_error_kind(model).expect("get_error_kind"), + ErrorKind::$error + ); + }; + (@insert, $db:expr, $show:literal, $season:literal, $tshow:literal, $tseason:literal $(; $($skip:ident),+)?) => { + super::seasons::ActiveModel { + tmdb_show: notsettable!(tmdb_show, TmdbShowId::from_raw($tshow) $(, $($skip),+)?), + tmdb_season: notsettable!(tmdb_season, $tseason $(, $($skip),+)?), + flix_show: notsettable!(flix_show, ShowId::from_raw($show) $(, $($skip),+)?), + flix_season: notsettable!(flix_season, $season $(, $($skip),+)?), + last_update: notsettable!(last_update, NaiveDate::from_yo_opt(2000, $tshow).expect("NaiveDate::from_yo_opt") $(, $($skip),+)?), + }.insert($db).await + }; + } + assert_season!(&db, 1, 1, 1, 1, ForeignKeyViolation); + make_flix_season!(&db, 1, 1); + make_flix_season!(&db, 1, 2); + + assert_season!(&db, 1, 1, 1, 1, Success); + assert_season!(&db, 1, 1, 1, 1, UniqueViolation); + assert_season!(&db, 1, 1, 2, 1, UniqueViolation); + assert_season!(&db, 2, 1, 1, 1, UniqueViolation); + assert_season!(&db, 1, 2, 1, 2, Success); + assert_season!(&db, 1, 3, 1, 3, NotNullViolation; tmdb_show); + assert_season!(&db, 1, 4, 1, 4, NotNullViolation; tmdb_season); + assert_season!(&db, 1, 5, 1, 5, NotNullViolation; flix_show); + assert_season!(&db, 1, 6, 1, 6, NotNullViolation; flix_season); + assert_season!(&db, 1, 7, 1, 7, NotNullViolation; last_update); + + // Episodes + macro_rules! assert_episode { + ($db:expr, $show:literal, $season:literal, $episode:literal, $tshow:literal, $tseason:literal, $tepisode:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_episode!(@insert, $db, $show, $season, $episode, $tshow, $tseason, $tepisode $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.tmdb_show, TmdbShowId::from_raw($tshow)); + assert_eq!(model.tmdb_season, $tseason); + assert_eq!(model.tmdb_episode, $tepisode); + assert_eq!(model.flix_show, ShowId::from_raw($show)); + assert_eq!(model.flix_season, $season); + assert_eq!(model.flix_episode, $episode); + assert_eq!(model.last_update, NaiveDate::from_yo_opt(2000, $tshow).expect("NaiveDate::from_yo_opt")); + assert_eq!(model.runtime, Duration::from_secs($tshow).into()); + }; + ($db:expr, $show:literal, $season:literal, $episode:literal, $tshow:literal, $tseason:literal, $tepisode:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_episode!(@insert, $db, $show, $season, $episode, $tshow, $tseason, $tepisode $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!( + get_error_kind(model).expect("get_error_kind"), + ErrorKind::$error + ); + }; + (@insert, $db:expr, $show:literal, $season:literal, $episode:literal, $tshow:literal, $tseason:literal, $tepisode:literal $(; $($skip:ident),+)?) => { + super::episodes::ActiveModel { + tmdb_show: notsettable!(tmdb_show, TmdbShowId::from_raw($tshow) $(, $($skip),+)?), + tmdb_season: notsettable!(tmdb_season, $tseason $(, $($skip),+)?), + tmdb_episode: notsettable!(tmdb_episode, $tepisode $(, $($skip),+)?), + flix_show: notsettable!(flix_show, ShowId::from_raw($show) $(, $($skip),+)?), + flix_season: notsettable!(flix_season, $season $(, $($skip),+)?), + flix_episode: notsettable!(flix_episode, $episode $(, $($skip),+)?), + last_update: notsettable!(last_update, NaiveDate::from_yo_opt(2000, $tshow).expect("NaiveDate::from_yo_opt") $(, $($skip),+)?), + runtime: notsettable!(runtime, Duration::from_secs($tshow).into() $(, $($skip),+)?), + }.insert($db).await + }; + } + assert_episode!(&db, 1, 1, 1, 1, 1, 1, ForeignKeyViolation); + make_flix_episode!(&db, 1, 1, 1); + make_flix_episode!(&db, 1, 1, 2); + + assert_episode!(&db, 1, 1, 1, 1, 1, 1, Success); + assert_episode!(&db, 1, 1, 1, 1, 1, 1, UniqueViolation); + assert_episode!(&db, 1, 1, 1, 1, 2, 1, UniqueViolation); + assert_episode!(&db, 1, 1, 1, 2, 1, 1, UniqueViolation); + assert_episode!(&db, 1, 2, 1, 1, 1, 1, UniqueViolation); + assert_episode!(&db, 2, 1, 1, 1, 1, 1, UniqueViolation); + assert_episode!(&db, 1, 1, 2, 1, 1, 2, Success); + assert_episode!(&db, 1, 1, 3, 1, 1, 3, NotNullViolation; tmdb_show); + assert_episode!(&db, 1, 1, 3, 1, 1, 4, NotNullViolation; tmdb_season); + assert_episode!(&db, 1, 1, 3, 1, 1, 5, NotNullViolation; tmdb_episode); + assert_episode!(&db, 1, 1, 3, 1, 1, 6, NotNullViolation; flix_show); + assert_episode!(&db, 1, 1, 3, 1, 1, 7, NotNullViolation; flix_season); + assert_episode!(&db, 1, 1, 3, 1, 1, 8, NotNullViolation; flix_episode); + assert_episode!(&db, 1, 1, 3, 1, 1, 9, NotNullViolation; last_update); + assert_episode!(&db, 1, 1, 3, 1, 1, 10, NotNullViolation; runtime); + } +} diff --git a/crates/db/src/entity/tmdb/movies.rs b/crates/db/src/entity/tmdb/movies.rs new file mode 100644 index 0000000..23a34e3 --- /dev/null +++ b/crates/db/src/entity/tmdb/movies.rs @@ -0,0 +1,51 @@ +//! Movie entity + +use flix_model::id::MovieId as FlixId; +use flix_tmdb::model::id::{CollectionId, MovieId}; + +use seamantic::model::duration::Seconds; + +use chrono::NaiveDate; +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait, + EnumIter, PrimaryKeyTrait, Related, RelationDef, RelationTrait, +}; + +/// The database representation of a tmdb movie +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_tmdb_movies")] +pub struct Model { + /// The movie's TMDB ID + #[sea_orm(primary_key, auto_increment = false)] + pub tmdb_id: MovieId, + /// The movie's ID + pub flix_id: FlixId, + /// The date of the last update + pub last_update: NaiveDate, + /// The movie's runtime in seconds + pub runtime: Seconds, + /// The TMDB ID of the collection this movie belongs to + pub collection: Option, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation { + /// The collection this movie belongs to + #[sea_orm( + belongs_to = "super::collections::Entity", + from = "Column::Collection", + to = "super::collections::Column::TmdbId", + on_update = "Cascade", + on_delete = "Cascade" + )] + Collection, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Collection.def() + } +} diff --git a/crates/db/src/entity/tmdb/seasons.rs b/crates/db/src/entity/tmdb/seasons.rs new file mode 100644 index 0000000..3ca7826 --- /dev/null +++ b/crates/db/src/entity/tmdb/seasons.rs @@ -0,0 +1,51 @@ +//! Season entity + +use flix_model::id::ShowId as FlixId; +use flix_model::numbers::SeasonNumber; +use flix_tmdb::model::id::ShowId; + +use chrono::NaiveDate; +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait, + EnumIter, PrimaryKeyTrait, Related, RelationDef, RelationTrait, +}; + +/// The database representation of a tmdb season +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_tmdb_seasons")] +pub struct Model { + /// The season's show's TMDB ID + #[sea_orm(primary_key, auto_increment = false)] + pub tmdb_show: ShowId, + /// The season's TMDB season number + #[sea_orm(primary_key, auto_increment = false)] + pub tmdb_season: SeasonNumber, + /// The season's show's ID + pub flix_show: FlixId, + /// The season's number + pub flix_season: SeasonNumber, + /// The date of the last update + pub last_update: NaiveDate, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation { + /// The show this season belongs to + #[sea_orm( + belongs_to = "super::shows::Entity", + from = "Column::TmdbShow", + to = "super::shows::Column::TmdbId", + on_update = "Cascade", + on_delete = "Cascade" + )] + Show, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Show.def() + } +} diff --git a/crates/db/src/entity/tmdb/shows.rs b/crates/db/src/entity/tmdb/shows.rs new file mode 100644 index 0000000..b8ac976 --- /dev/null +++ b/crates/db/src/entity/tmdb/shows.rs @@ -0,0 +1,41 @@ +//! Show entity + +use flix_model::id::ShowId as FlixId; +use flix_tmdb::model::id::ShowId; + +use chrono::NaiveDate; +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait, + EnumIter, PrimaryKeyTrait, Related, RelationDef, RelationTrait, +}; + +/// The database representation of a tmdb show +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_tmdb_shows")] +pub struct Model { + /// The show's TMDB ID + #[sea_orm(primary_key, auto_increment = false)] + pub tmdb_id: ShowId, + /// The show's ID + pub flix_id: FlixId, + /// The movie's runtime in seconds + pub last_update: NaiveDate, + /// The number of seasons the show has + pub number_of_seasons: u32, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation { + /// The seasons that are part of this show + #[sea_orm(has_many = "super::seasons::Entity")] + Seasons, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Seasons.def() + } +} diff --git a/crates/db/src/entity/watched/collections.rs b/crates/db/src/entity/watched/collections.rs new file mode 100644 index 0000000..afd601e --- /dev/null +++ b/crates/db/src/entity/watched/collections.rs @@ -0,0 +1,26 @@ +//! Collection entity + +use flix_model::id::{CollectionId, RawId}; + +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EnumIter, + PrimaryKeyTrait, +}; + +/// The database representation of a watched movie +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_watched_collections")] +pub struct Model { + /// The collection's ID + #[sea_orm(primary_key, auto_increment = false)] + pub id: CollectionId, + /// The user's ID + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: RawId, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/crates/db/src/entity/watched/episodes.rs b/crates/db/src/entity/watched/episodes.rs new file mode 100644 index 0000000..2d948db --- /dev/null +++ b/crates/db/src/entity/watched/episodes.rs @@ -0,0 +1,36 @@ +//! Episode entity + +use flix_model::id::{RawId, ShowId}; +use flix_model::numbers::{EpisodeNumber, SeasonNumber}; + +use chrono::NaiveDate; +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EnumIter, + PrimaryKeyTrait, +}; + +/// The database representation of a watched movie +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_watched_episodes")] +pub struct Model { + /// The episode's show's ID + #[sea_orm(primary_key, auto_increment = false)] + pub show: ShowId, + /// The episode's season's number + #[sea_orm(primary_key, auto_increment = false)] + pub season: SeasonNumber, + /// The episode's number + #[sea_orm(primary_key, auto_increment = false)] + pub episode: EpisodeNumber, + /// The user's ID + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: RawId, + /// The date this episode was watched + pub watched_date: NaiveDate, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/crates/db/src/entity/watched/mod.rs b/crates/db/src/entity/watched/mod.rs new file mode 100644 index 0000000..8f16927 --- /dev/null +++ b/crates/db/src/entity/watched/mod.rs @@ -0,0 +1,291 @@ +//! This module contains entities for storing watched information + +pub mod collections; + +pub mod movies; + +pub mod episodes; +pub mod seasons; +pub mod shows; + +#[cfg(test)] +mod tests { + + use flix_model::id::{MovieId, ShowId}; + + use chrono::NaiveDate; + use sea_orm::ActiveValue::{NotSet, Set}; + use sea_orm::sqlx::error::ErrorKind; + use sea_orm::{ActiveModelTrait, ColumnTrait, Condition, EntityTrait, QueryFilter}; + use sea_orm_migration::MigratorTrait; + + use crate::entity::tests::{have_collection, have_library, have_movie, have_season, have_show}; + use crate::migration::Migrator; + use crate::tests::new_memory_db; + + use super::super::tests::get_error_kind; + use super::super::tests::notsettable; + use super::super::tests::{ + have_episode, make_flix_episode, make_flix_movie, make_flix_season, make_flix_show, + }; + + macro_rules! assert_movie { + ($db:expr, $id:literal, $uid:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_movie!(@insert, $db, $id, $uid $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.id, MovieId::from_raw($id)); + assert_eq!(model.user_id, $uid); + assert_eq!(model.watched_date, NaiveDate::from_yo_opt(2000, $uid).expect("NaiveDate::from_yo_opt")); + }; + ($db:expr, $id:literal, $uid:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_movie!(@insert, $db, $id, $uid $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error); + }; + (@insert, $db:expr, $id:literal, $uid:literal $(; $($skip:ident),+)?) => { + super::movies::ActiveModel { + id: notsettable!(id, MovieId::from_raw($id) $(, $($skip),+)?), + user_id: notsettable!(user_id, $uid $(, $($skip),+)?), + watched_date: notsettable!(watched_date, NaiveDate::from_yo_opt(2000, $uid).expect("NaiveDate::from_yo_opt") $(, $($skip),+)?), + }.insert($db).await + }; + } + + macro_rules! assert_episode { + ($db:expr, $show:literal, $season:literal, $episode:literal, $uid:literal, Success $(; $($skip:ident),+)?) => { + let model = assert_episode!(@insert, $db, $show, $season, $episode, $uid $(; $($skip),+)?) + .expect("insert"); + + assert_eq!(model.show, ShowId::from_raw($show)); + assert_eq!(model.season, $season); + assert_eq!(model.episode, $episode); + assert_eq!(model.user_id, $uid); + assert_eq!(model.watched_date, NaiveDate::from_yo_opt(2000, $uid).expect("NaiveDate::from_yo_opt")); + }; + ($db:expr, $show:literal, $season:literal, $episode:literal, $uid:literal, $error:ident $(; $($skip:ident),+)?) => { + let model = assert_episode!(@insert, $db, $show, $season, $episode, $uid $(; $($skip),+)?) + .expect_err("insert"); + + assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error); + }; + (@insert, $db:expr, $show:literal, $season:literal, $episode:literal, $uid:literal $(; $($skip:ident),+)?) => { + super::episodes::ActiveModel { + show: notsettable!(show, ShowId::from_raw($show) $(, $($skip),+)?), + season: notsettable!(season, $season $(, $($skip),+)?), + episode: notsettable!(episode, $episode $(, $($skip),+)?), + user_id: notsettable!(user_id, $uid $(, $($skip),+)?), + watched_date: notsettable!(watched_date, NaiveDate::from_yo_opt(2000, $uid).expect("NaiveDate::from_yo_opt") $(, $($skip),+)?), + }.insert($db).await + }; + } + + #[tokio::test] + async fn test_inserts() { + let db = new_memory_db().await; + Migrator::up(&db, None).await.expect("up"); + + // Movies + assert_movie!(&db, 1, 1, ForeignKeyViolation); + make_flix_movie!(&db, 1); + make_flix_movie!(&db, 2); + + assert_movie!(&db, 1, 1, Success); + assert_movie!(&db, 1, 1, UniqueViolation); + assert_movie!(&db, 2, 1, Success); + assert_movie!(&db, 3, 1, NotNullViolation; id); + assert_movie!(&db, 4, 1, NotNullViolation; user_id); + assert_movie!(&db, 5, 1, NotNullViolation; watched_date); + + // Episodes + make_flix_show!(&db, 1); + make_flix_season!(&db, 1, 1); + make_flix_show!(&db, 2); + make_flix_season!(&db, 2, 1); + + assert_episode!(&db, 1, 1, 1, 1, ForeignKeyViolation); + make_flix_episode!(&db, 1, 1, 1); + make_flix_episode!(&db, 1, 1, 2); + make_flix_episode!(&db, 2, 1, 1); + + assert_episode!(&db, 1, 1, 1, 1, Success); + assert_episode!(&db, 1, 1, 1, 1, UniqueViolation); + assert_episode!(&db, 1, 1, 2, 1, Success); + assert_episode!(&db, 2, 1, 1, 1, Success); + assert_episode!(&db, 3, 1, 1, 1, NotNullViolation; show); + assert_episode!(&db, 4, 1, 1, 1, NotNullViolation; season); + assert_episode!(&db, 5, 1, 1, 1, NotNullViolation; episode); + assert_episode!(&db, 6, 1, 1, 1, NotNullViolation; user_id); + assert_episode!(&db, 7, 1, 1, 1, NotNullViolation; watched_date); + } + + #[tokio::test] + async fn test_queries() { + let db = new_memory_db().await; + Migrator::up(&db, None).await.expect("up"); + + have_library!(&db, 1); + + // Season + Show + macro_rules! assert_season { + ($db:expr, $show:literal, $season:literal, $uid:literal, Watched) => { + assert_season!(@find, $db, $show, $season, $uid) + .ok_or(()) + .expect("is none"); + }; + ($db:expr, $show:literal, $season:literal, $uid:literal, Unwatched) => { + assert_season!(@find, $db, $show, $season, $uid) + .ok_or(()) + .expect_err("is some"); + }; + (@find, $db:expr, $show:literal, $season:literal, $uid:literal) => { + super::seasons::Entity::find() + .filter( + Condition::all() + .add(super::seasons::Column::Show.eq($show)) + .add(super::seasons::Column::Season.eq($season)) + .add(super::seasons::Column::UserId.eq($uid)), + ) + .one(&db) + .await + .expect("find.filter.one") + }; + } + macro_rules! assert_show { + ($db:expr, $show:literal, $uid:literal, Watched) => { + assert_show!(@find, $db, $show, $uid) + .ok_or(()) + .expect("is none"); + }; + ($db:expr, $show:literal, $uid:literal, Unwatched) => { + assert_show!(@find, $db, $show, $uid) + .ok_or(()) + .expect_err("is some"); + }; + (@find, $db:expr, $show:literal, $uid:literal) => { + super::shows::Entity::find() + .filter( + Condition::all() + .add(super::shows::Column::Id.eq($show)) + .add(super::shows::Column::UserId.eq($uid)), + ) + .one(&db) + .await + .expect("find.filter.one") + }; + } + + have_show!(&db, 1, 1, None); + have_season!(&db, 1, 1, 1); + have_episode!(&db, 1, 1, 1, 1); + assert_episode!(&db, 1, 1, 1, 1, Success); + assert_episode!(&db, 1, 1, 1, 2, Success); + have_episode!(&db, 1, 1, 1, 2); + assert_episode!(&db, 1, 1, 2, 1, Success); + assert_episode!(&db, 1, 1, 2, 2, Success); + have_episode!(&db, 1, 1, 1, 3); + assert_episode!(&db, 1, 1, 3, 1, Success); + assert_episode!(&db, 1, 1, 3, 2, Success); + have_season!(&db, 1, 1, 2); + have_episode!(&db, 1, 1, 2, 1); + assert_episode!(&db, 1, 2, 1, 1, Success); + assert_episode!(&db, 1, 2, 1, 2, Success); + have_episode!(&db, 1, 1, 2, 2); + assert_episode!(&db, 1, 2, 2, 1, Success); + have_episode!(&db, 1, 1, 2, 3); + assert_episode!(&db, 1, 2, 3, 1, Success); + + // Add watched episodes that we do not have + make_flix_episode!(&db, 1, 1, 4); + assert_episode!(&db, 1, 1, 4, 1, Success); + make_flix_episode!(&db, 1, 2, 4); + assert_episode!(&db, 1, 2, 4, 2, Success); + make_flix_episode!(&db, 1, 2, 5); + assert_episode!(&db, 1, 2, 5, 2, Success); + + assert_season!(&db, 1, 1, 1, Watched); + assert_season!(&db, 1, 1, 2, Watched); + assert_season!(&db, 1, 1, 3, Unwatched); + assert_season!(&db, 1, 2, 1, Watched); + assert_season!(&db, 1, 2, 2, Unwatched); + assert_season!(&db, 1, 2, 3, Unwatched); + assert_season!(&db, 1, 3, 1, Unwatched); + assert_season!(&db, 1, 3, 2, Unwatched); + assert_season!(&db, 1, 3, 3, Unwatched); + + assert_show!(&db, 1, 1, Watched); + assert_show!(&db, 1, 2, Unwatched); + assert_show!(&db, 1, 3, Unwatched); + assert_show!(&db, 2, 1, Unwatched); + assert_show!(&db, 2, 2, Unwatched); + assert_show!(&db, 2, 3, Unwatched); + + // Collection + macro_rules! assert_collection { + ($db:expr, $id:literal, $uid:literal, Watched) => { + assert_collection!(@find, $db, $id, $uid) + .ok_or(()) + .expect("is none"); + }; + ($db:expr, $id:literal, $uid:literal, Unwatched) => { + assert_collection!(@find, $db, $id, $uid) + .ok_or(()) + .expect_err("is some"); + }; + (@find, $db:expr, $id:literal, $uid:literal) => { + super::collections::Entity::find() + .filter( + Condition::all() + .add(super::collections::Column::Id.eq($id)) + .add(super::collections::Column::UserId.eq($uid)), + ) + .one(&db) + .await + .expect("find.filter.one") + }; + } + + have_collection!(&db, 1, 1, None); + have_movie!(&db, 1, 1, Some(1)); + assert_movie!(&db, 1, 1, Success); + assert_movie!(&db, 1, 2, Success); + have_movie!(&db, 1, 2, Some(1)); + assert_movie!(&db, 2, 1, Success); + assert_movie!(&db, 2, 2, Success); + + have_collection!(&db, 1, 2, Some(1)); + have_movie!(&db, 1, 3, Some(2)); + have_show!(&db, 1, 2, Some(2)); + have_season!(&db, 1, 2, 1); + have_episode!(&db, 1, 2, 1, 1); + assert_episode!(&db, 2, 1, 1, 1, Success); + assert_movie!(&db, 3, 1, Success); + have_movie!(&db, 1, 4, Some(2)); + assert_movie!(&db, 4, 1, Success); + + have_collection!(&db, 1, 3, Some(2)); + have_movie!(&db, 1, 5, Some(3)); + assert_movie!(&db, 5, 1, Success); + have_movie!(&db, 1, 6, Some(3)); + assert_movie!(&db, 6, 1, Success); + assert_movie!(&db, 6, 2, Success); + + have_collection!(&db, 1, 4, Some(3)); + have_movie!(&db, 1, 7, Some(4)); + assert_movie!(&db, 7, 1, Success); + assert_movie!(&db, 7, 2, Success); + have_movie!(&db, 1, 8, Some(4)); + assert_movie!(&db, 8, 1, Success); + assert_movie!(&db, 8, 2, Success); + + assert_collection!(&db, 1, 1, Watched); + assert_collection!(&db, 1, 2, Unwatched); + assert_collection!(&db, 2, 1, Watched); + assert_collection!(&db, 2, 2, Unwatched); + assert_collection!(&db, 3, 1, Watched); + assert_collection!(&db, 3, 2, Unwatched); + assert_collection!(&db, 4, 1, Watched); + assert_collection!(&db, 4, 2, Watched); + } +} diff --git a/crates/db/src/entity/watched/movies.rs b/crates/db/src/entity/watched/movies.rs new file mode 100644 index 0000000..2cc2bb3 --- /dev/null +++ b/crates/db/src/entity/watched/movies.rs @@ -0,0 +1,29 @@ +//! Movie entity + +use flix_model::id::{MovieId, RawId}; + +use chrono::NaiveDate; +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EnumIter, + PrimaryKeyTrait, +}; + +/// The database representation of a watched movie +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_watched_movies")] +pub struct Model { + /// The movie's ID + #[sea_orm(primary_key, auto_increment = false)] + pub id: MovieId, + /// The user's ID + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: RawId, + /// The date this movie was watched + pub watched_date: NaiveDate, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/crates/db/src/entity/watched/seasons.rs b/crates/db/src/entity/watched/seasons.rs new file mode 100644 index 0000000..ccb1418 --- /dev/null +++ b/crates/db/src/entity/watched/seasons.rs @@ -0,0 +1,33 @@ +//! Episode entity + +use flix_model::id::{RawId, ShowId}; +use flix_model::numbers::SeasonNumber; + +use chrono::NaiveDate; +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EnumIter, + PrimaryKeyTrait, +}; + +/// The database representation of a watched movie +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_watched_seasons")] +pub struct Model { + /// The season's show's ID + #[sea_orm(primary_key, auto_increment = false)] + pub show: ShowId, + /// The season's number + #[sea_orm(primary_key, auto_increment = false)] + pub season: SeasonNumber, + /// The user's ID + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: RawId, + /// The date this season was watched + pub watched_date: NaiveDate, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/crates/db/src/entity/watched/shows.rs b/crates/db/src/entity/watched/shows.rs new file mode 100644 index 0000000..4c8bfa6 --- /dev/null +++ b/crates/db/src/entity/watched/shows.rs @@ -0,0 +1,29 @@ +//! Show entity + +use flix_model::id::{RawId, ShowId}; + +use chrono::NaiveDate; +use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EnumIter, + PrimaryKeyTrait, +}; + +/// The database representation of a watched movie +#[derive(Debug, Clone, DeriveEntityModel)] +#[sea_orm(table_name = "flix_watched_shows")] +pub struct Model { + /// The show's ID + #[sea_orm(primary_key, auto_increment = false)] + pub id: ShowId, + /// The user's ID + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: RawId, + /// The date this show was watched + pub watched_date: NaiveDate, +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Relation +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs new file mode 100644 index 0000000..8a58a81 --- /dev/null +++ b/crates/db/src/lib.rs @@ -0,0 +1,17 @@ +//! flix-db provides types for storing persistent data about media + +#![cfg_attr(docsrs, feature(doc_cfg))] + +pub mod connection; +pub mod entity; +pub mod migration; + +#[cfg(test)] +mod tests { + use sea_orm::{ConnectOptions, Database, DatabaseConnection}; + + pub async fn new_memory_db() -> DatabaseConnection { + let options = ConnectOptions::new("sqlite:/tmp/db?mode=memory"); + Database::connect(options).await.expect("Database::connect") + } +} diff --git a/crates/db/src/migration/m_000001.rs b/crates/db/src/migration/m_000001.rs new file mode 100644 index 0000000..38dadee --- /dev/null +++ b/crates/db/src/migration/m_000001.rs @@ -0,0 +1,53 @@ +//! Adds entity/info tables: +//! - Collections +//! - Movies +//! - Shows +//! - Seasons +//! - Episodes + +use sea_orm::{DbErr, DeriveMigrationName}; +use sea_orm_migration::async_trait; +use sea_orm_migration::{MigrationTrait, SchemaManager}; + +mod collections; +mod episodes; +mod movies; +mod seasons; +mod shows; + +#[allow(unused_imports)] +pub use collections::FlixInfoCollections; +#[allow(unused_imports)] +pub use episodes::FlixInfoEpisodes; +#[allow(unused_imports)] +pub use movies::FlixInfoMovies; +#[allow(unused_imports)] +pub use seasons::FlixInfoSeasons; +#[allow(unused_imports)] +pub use shows::FlixInfoShows; + +#[derive(DeriveMigrationName)] +pub(super) struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + collections::up(manager).await?; + movies::up(manager).await?; + shows::up(manager).await?; + seasons::up(manager).await?; + episodes::up(manager).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + episodes::down(manager).await?; + seasons::down(manager).await?; + shows::down(manager).await?; + movies::down(manager).await?; + collections::down(manager).await?; + + Ok(()) + } +} diff --git a/crates/db/src/migration/m_000001/collections.rs b/crates/db/src/migration/m_000001/collections.rs new file mode 100644 index 0000000..bbfda26 --- /dev/null +++ b/crates/db/src/migration/m_000001/collections.rs @@ -0,0 +1,46 @@ +use seamantic::schema::sqlite_rowid_alias; + +use sea_orm::sea_query; +use sea_orm::sea_query::{Index, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::string; + +#[derive(Iden)] +pub enum FlixInfoCollections { + Table, + Id, + Title, + Overview, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixInfoCollections::Table) + .col(sqlite_rowid_alias(FlixInfoCollections::Id)) + .col(string(FlixInfoCollections::Title)) + .col(string(FlixInfoCollections::Overview)) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx-flix_info_collections-title") + .table(FlixInfoCollections::Table) + .col(FlixInfoCollections::Title) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixInfoCollections::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000001/episodes.rs b/crates/db/src/migration/m_000001/episodes.rs new file mode 100644 index 0000000..189bbb8 --- /dev/null +++ b/crates/db/src/migration/m_000001/episodes.rs @@ -0,0 +1,68 @@ +use sea_orm::sea_query; +use sea_orm::sea_query::{ForeignKeyCreateStatement, Index, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::{date, integer, string}; + +use crate::migration::m_000001::FlixInfoShows; + +use super::FlixInfoSeasons; + +#[derive(Iden)] +pub enum FlixInfoEpisodes { + Table, + Show, + Season, + Episode, + Title, + Overview, + Date, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixInfoEpisodes::Table) + .col(integer(FlixInfoEpisodes::Show)) + .col(integer(FlixInfoEpisodes::Season)) + .col(integer(FlixInfoEpisodes::Episode)) + .col(string(FlixInfoEpisodes::Title)) + .col(string(FlixInfoEpisodes::Overview)) + .col(date(FlixInfoEpisodes::Date)) + .primary_key( + Index::create() + .col(FlixInfoEpisodes::Show) + .col(FlixInfoEpisodes::Season) + .col(FlixInfoEpisodes::Episode), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_info_episodes-show") + .from_tbl(FlixInfoEpisodes::Table) + .from_col(FlixInfoEpisodes::Show) + .to_tbl(FlixInfoShows::Table) + .to_col(FlixInfoShows::Id), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_info_episodes-show_season") + .from_tbl(FlixInfoEpisodes::Table) + .from_col(FlixInfoEpisodes::Show) + .from_col(FlixInfoEpisodes::Season) + .to_tbl(FlixInfoSeasons::Table) + .to_col(FlixInfoSeasons::Show) + .to_col(FlixInfoSeasons::Season), + ) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixInfoEpisodes::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000001/movies.rs b/crates/db/src/migration/m_000001/movies.rs new file mode 100644 index 0000000..c9812f4 --- /dev/null +++ b/crates/db/src/migration/m_000001/movies.rs @@ -0,0 +1,60 @@ +use seamantic::schema::sqlite_rowid_alias; + +use sea_orm::sea_query; +use sea_orm::sea_query::{Index, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::{date, string}; + +#[derive(Iden)] +pub enum FlixInfoMovies { + Table, + Id, + Title, + Tagline, + Overview, + Date, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixInfoMovies::Table) + .col(sqlite_rowid_alias(FlixInfoMovies::Id)) + .col(string(FlixInfoMovies::Title)) + .col(string(FlixInfoMovies::Tagline)) + .col(string(FlixInfoMovies::Overview)) + .col(date(FlixInfoMovies::Date)) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx-flix_info_movies-title") + .table(FlixInfoMovies::Table) + .col(FlixInfoMovies::Title) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx-flix_info_movies-date") + .table(FlixInfoMovies::Table) + .col(FlixInfoMovies::Date) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixInfoMovies::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000001/seasons.rs b/crates/db/src/migration/m_000001/seasons.rs new file mode 100644 index 0000000..1c24af0 --- /dev/null +++ b/crates/db/src/migration/m_000001/seasons.rs @@ -0,0 +1,53 @@ +use sea_orm::sea_query; +use sea_orm::sea_query::{ForeignKeyCreateStatement, Index, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::{date, integer, string}; + +use super::FlixInfoShows; + +#[derive(Iden)] +pub enum FlixInfoSeasons { + Table, + Show, + Season, + Title, + Overview, + Date, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixInfoSeasons::Table) + .col(integer(FlixInfoSeasons::Show)) + .col(integer(FlixInfoSeasons::Season)) + .col(string(FlixInfoSeasons::Title)) + .col(string(FlixInfoSeasons::Overview)) + .col(date(FlixInfoSeasons::Date)) + .primary_key( + Index::create() + .col(FlixInfoSeasons::Show) + .col(FlixInfoSeasons::Season), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_info_seasons-show") + .from_tbl(FlixInfoSeasons::Table) + .from_col(FlixInfoSeasons::Show) + .to_tbl(FlixInfoShows::Table) + .to_col(FlixInfoShows::Id), + ) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixInfoSeasons::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000001/shows.rs b/crates/db/src/migration/m_000001/shows.rs new file mode 100644 index 0000000..73df566 --- /dev/null +++ b/crates/db/src/migration/m_000001/shows.rs @@ -0,0 +1,60 @@ +use seamantic::schema::sqlite_rowid_alias; + +use sea_orm::sea_query; +use sea_orm::sea_query::{Index, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::{date, string}; + +#[derive(Iden)] +pub enum FlixInfoShows { + Table, + Id, + Title, + Tagline, + Overview, + Date, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixInfoShows::Table) + .col(sqlite_rowid_alias(FlixInfoShows::Id)) + .col(string(FlixInfoShows::Title)) + .col(string(FlixInfoShows::Tagline)) + .col(string(FlixInfoShows::Overview)) + .col(date(FlixInfoShows::Date)) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx-flix_info_shows-title") + .table(FlixInfoShows::Table) + .col(FlixInfoShows::Title) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx-flix_info_shows-date") + .table(FlixInfoShows::Table) + .col(FlixInfoShows::Date) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixInfoShows::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000002.rs b/crates/db/src/migration/m_000002.rs new file mode 100644 index 0000000..9d6c532 --- /dev/null +++ b/crates/db/src/migration/m_000002.rs @@ -0,0 +1,53 @@ +//! Adds entity/tmdb tables: +//! - Collections +//! - Movies +//! - Shows +//! - Seasons +//! - Episodes + +use sea_orm::{DbErr, DeriveMigrationName}; +use sea_orm_migration::async_trait; +use sea_orm_migration::{MigrationTrait, SchemaManager}; + +mod collections; +mod episodes; +mod movies; +mod seasons; +mod shows; + +#[allow(unused_imports)] +pub use collections::FlixTmdbCollections; +#[allow(unused_imports)] +pub use episodes::FlixTmdbEpisodes; +#[allow(unused_imports)] +pub use movies::FlixTmdbMovies; +#[allow(unused_imports)] +pub use seasons::FlixTmdbSeasons; +#[allow(unused_imports)] +pub use shows::FlixTmdbShows; + +#[derive(DeriveMigrationName)] +pub(super) struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + collections::up(manager).await?; + movies::up(manager).await?; + shows::up(manager).await?; + seasons::up(manager).await?; + episodes::up(manager).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + episodes::down(manager).await?; + seasons::down(manager).await?; + shows::down(manager).await?; + movies::down(manager).await?; + collections::down(manager).await?; + + Ok(()) + } +} diff --git a/crates/db/src/migration/m_000002/collections.rs b/crates/db/src/migration/m_000002/collections.rs new file mode 100644 index 0000000..9a18a25 --- /dev/null +++ b/crates/db/src/migration/m_000002/collections.rs @@ -0,0 +1,58 @@ +use seamantic::schema::sqlite_rowid_alias; + +use sea_orm::sea_query; +use sea_orm::sea_query::{ForeignKeyCreateStatement, Index, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::{date, integer}; + +use super::super::m_000001::FlixInfoCollections; + +#[derive(Iden)] +pub enum FlixTmdbCollections { + Table, + TmdbId, + FlixId, + LastUpdate, + MovieCount, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixTmdbCollections::Table) + .col(sqlite_rowid_alias(FlixTmdbCollections::TmdbId)) + .col(integer(FlixTmdbCollections::FlixId).unique_key()) + .col(date(FlixTmdbCollections::LastUpdate)) + .col(integer(FlixTmdbCollections::MovieCount)) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_tmdb_collections-flixid") + .from_tbl(FlixTmdbCollections::Table) + .from_col(FlixTmdbCollections::FlixId) + .to_tbl(FlixInfoCollections::Table) + .to_col(FlixInfoCollections::Id), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx-flix_tmdb_collections-flixid") + .table(FlixTmdbCollections::Table) + .col(FlixTmdbCollections::FlixId) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixTmdbCollections::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000002/episodes.rs b/crates/db/src/migration/m_000002/episodes.rs new file mode 100644 index 0000000..a182087 --- /dev/null +++ b/crates/db/src/migration/m_000002/episodes.rs @@ -0,0 +1,94 @@ +use sea_orm::sea_query; +use sea_orm::sea_query::{ForeignKeyCreateStatement, Index, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::{date, integer}; + +use super::super::m_000001::{FlixInfoEpisodes, FlixInfoSeasons}; + +use super::FlixTmdbSeasons; + +#[derive(Iden)] +pub enum FlixTmdbEpisodes { + Table, + TmdbShow, + TmdbSeason, + TmdbEpisode, + FlixShow, + FlixSeason, + FlixEpisode, + LastUpdate, + Runtime, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixTmdbEpisodes::Table) + .col(integer(FlixTmdbEpisodes::TmdbShow)) + .col(integer(FlixTmdbEpisodes::TmdbSeason)) + .col(integer(FlixTmdbEpisodes::TmdbEpisode)) + .col(integer(FlixTmdbEpisodes::FlixShow)) + .col(integer(FlixTmdbEpisodes::FlixSeason)) + .col(integer(FlixTmdbEpisodes::FlixEpisode)) + .col(date(FlixTmdbEpisodes::LastUpdate)) + .col(integer(FlixTmdbEpisodes::Runtime)) + .primary_key( + Index::create() + .col(FlixTmdbEpisodes::TmdbShow) + .col(FlixTmdbEpisodes::TmdbSeason) + .col(FlixTmdbEpisodes::TmdbEpisode), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_tmdb_episodes-tmdb_show_season") + .from_tbl(FlixTmdbEpisodes::Table) + .from_col(FlixTmdbEpisodes::TmdbShow) + .from_col(FlixTmdbEpisodes::TmdbSeason) + .to_tbl(FlixTmdbSeasons::Table) + .to_col(FlixTmdbSeasons::TmdbShow) + .to_col(FlixTmdbSeasons::TmdbSeason), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_tmdb_episodes-flix_show_season") + .from_tbl(FlixTmdbEpisodes::Table) + .from_col(FlixTmdbEpisodes::FlixShow) + .from_col(FlixTmdbEpisodes::FlixSeason) + .to_tbl(FlixInfoSeasons::Table) + .to_col(FlixInfoSeasons::Show) + .to_col(FlixInfoSeasons::Season), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_tmdb_episodes-flix_show_season_episode") + .from_tbl(FlixTmdbEpisodes::Table) + .from_col(FlixTmdbEpisodes::FlixShow) + .from_col(FlixTmdbEpisodes::FlixSeason) + .from_col(FlixTmdbEpisodes::FlixEpisode) + .to_tbl(FlixInfoEpisodes::Table) + .to_col(FlixInfoEpisodes::Show) + .to_col(FlixInfoEpisodes::Season) + .to_col(FlixInfoEpisodes::Episode), + ) + .index( + Index::create() + .unique() + .name("idx-flix_tmdb_episodes-flix_show_season_episode") + .col(FlixTmdbEpisodes::FlixShow) + .col(FlixTmdbEpisodes::FlixSeason) + .col(FlixTmdbEpisodes::FlixEpisode), + ) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixTmdbEpisodes::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000002/movies.rs b/crates/db/src/migration/m_000002/movies.rs new file mode 100644 index 0000000..ccb2d70 --- /dev/null +++ b/crates/db/src/migration/m_000002/movies.rs @@ -0,0 +1,70 @@ +use seamantic::schema::sqlite_rowid_alias; + +use sea_orm::sea_query; +use sea_orm::sea_query::{ForeignKeyCreateStatement, Index, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::{date, integer, integer_null}; + +use crate::migration::m_000002::FlixTmdbCollections; + +use super::super::m_000001::FlixInfoMovies; + +#[derive(Iden)] +pub enum FlixTmdbMovies { + Table, + TmdbId, + FlixId, + LastUpdate, + Runtime, + Collection, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixTmdbMovies::Table) + .col(sqlite_rowid_alias(FlixTmdbMovies::TmdbId)) + .col(integer(FlixTmdbMovies::FlixId).unique_key()) + .col(date(FlixTmdbMovies::LastUpdate)) + .col(integer(FlixTmdbMovies::Runtime)) + .col(integer_null(FlixTmdbMovies::Collection)) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_tmdb_movies-flixid") + .from_tbl(FlixTmdbMovies::Table) + .from_col(FlixTmdbMovies::FlixId) + .to_tbl(FlixInfoMovies::Table) + .to_col(FlixInfoMovies::Id), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_tmdb_movies-collectionid") + .from_tbl(FlixTmdbMovies::Table) + .from_col(FlixTmdbMovies::Collection) + .to_tbl(FlixTmdbCollections::Table) + .to_col(FlixTmdbCollections::TmdbId), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx-flix_tmdb_movies-flixid") + .table(FlixTmdbMovies::Table) + .col(FlixTmdbMovies::FlixId) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixTmdbMovies::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000002/seasons.rs b/crates/db/src/migration/m_000002/seasons.rs new file mode 100644 index 0000000..b1b8c34 --- /dev/null +++ b/crates/db/src/migration/m_000002/seasons.rs @@ -0,0 +1,82 @@ +use sea_orm::sea_query; +use sea_orm::sea_query::{ForeignKeyCreateStatement, Index, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::{date, integer}; + +use crate::migration::m_000001::FlixInfoShows; + +use super::super::m_000001::FlixInfoSeasons; + +use super::FlixTmdbShows; + +#[derive(Iden)] +pub enum FlixTmdbSeasons { + Table, + TmdbShow, + TmdbSeason, + FlixShow, + FlixSeason, + LastUpdate, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixTmdbSeasons::Table) + .col(integer(FlixTmdbSeasons::TmdbShow)) + .col(integer(FlixTmdbSeasons::TmdbSeason)) + .col(integer(FlixTmdbSeasons::FlixShow)) + .col(integer(FlixTmdbSeasons::FlixSeason)) + .col(date(FlixTmdbSeasons::LastUpdate)) + .primary_key( + Index::create() + .col(FlixTmdbSeasons::TmdbShow) + .col(FlixTmdbSeasons::TmdbSeason), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_tmdb_seasons-tmdb_show") + .from_tbl(FlixTmdbSeasons::Table) + .from_col(FlixTmdbSeasons::FlixShow) + .to_tbl(FlixTmdbShows::Table) + .to_col(FlixTmdbShows::FlixId), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_tmdb_seasons-flix_show") + .from_tbl(FlixTmdbSeasons::Table) + .from_col(FlixTmdbSeasons::FlixShow) + .to_tbl(FlixInfoShows::Table) + .to_col(FlixInfoShows::Id), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_tmdb_seasons-flix_show_season") + .from_tbl(FlixTmdbSeasons::Table) + .from_col(FlixTmdbSeasons::FlixShow) + .from_col(FlixTmdbSeasons::FlixSeason) + .to_tbl(FlixInfoSeasons::Table) + .to_col(FlixInfoSeasons::Show) + .to_col(FlixInfoSeasons::Season), + ) + .index( + Index::create() + .unique() + .name("idx-flix_tmdb_seasons-flix_show_season") + .col(FlixTmdbSeasons::FlixShow) + .col(FlixTmdbSeasons::FlixSeason), + ) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixTmdbSeasons::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000002/shows.rs b/crates/db/src/migration/m_000002/shows.rs new file mode 100644 index 0000000..9df839c --- /dev/null +++ b/crates/db/src/migration/m_000002/shows.rs @@ -0,0 +1,58 @@ +use seamantic::schema::sqlite_rowid_alias; + +use sea_orm::sea_query; +use sea_orm::sea_query::{ForeignKeyCreateStatement, Index, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::{date, integer}; + +use super::super::m_000001::FlixInfoShows; + +#[derive(Iden)] +pub enum FlixTmdbShows { + Table, + TmdbId, + FlixId, + LastUpdate, + NumberOfSeasons, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixTmdbShows::Table) + .col(sqlite_rowid_alias(FlixTmdbShows::TmdbId)) + .col(integer(FlixTmdbShows::FlixId).unique_key()) + .col(date(FlixTmdbShows::LastUpdate)) + .col(integer(FlixTmdbShows::NumberOfSeasons)) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_tmdb_shows-flixid") + .from_tbl(FlixTmdbShows::Table) + .from_col(FlixTmdbShows::FlixId) + .to_tbl(FlixInfoShows::Table) + .to_col(FlixInfoShows::Id), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx-flix_tmdb_shows-flixid") + .table(FlixTmdbShows::Table) + .col(FlixTmdbShows::FlixId) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixTmdbShows::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000003.rs b/crates/db/src/migration/m_000003.rs new file mode 100644 index 0000000..4fd3e6b --- /dev/null +++ b/crates/db/src/migration/m_000003.rs @@ -0,0 +1,59 @@ +//! Adds entity/content tables: +//! - Libraries +//! - Collections +//! - Movies +//! - Shows +//! - Seasons +//! - Episodes + +use sea_orm::{DbErr, DeriveMigrationName}; +use sea_orm_migration::async_trait; +use sea_orm_migration::{MigrationTrait, SchemaManager}; + +mod collections; +mod episodes; +mod libraries; +mod movies; +mod seasons; +mod shows; + +#[allow(unused_imports)] +pub use collections::FlixCollections; +#[allow(unused_imports)] +pub use episodes::FlixEpisodes; +#[allow(unused_imports)] +pub use libraries::FlixLibraries; +#[allow(unused_imports)] +pub use movies::FlixMovies; +#[allow(unused_imports)] +pub use seasons::FlixSeasons; +#[allow(unused_imports)] +pub use shows::FlixShows; + +#[derive(DeriveMigrationName)] +pub(super) struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + libraries::up(manager).await?; + collections::up(manager).await?; + movies::up(manager).await?; + shows::up(manager).await?; + seasons::up(manager).await?; + episodes::up(manager).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + episodes::down(manager).await?; + seasons::down(manager).await?; + shows::down(manager).await?; + movies::down(manager).await?; + collections::down(manager).await?; + libraries::down(manager).await?; + + Ok(()) + } +} diff --git a/crates/db/src/migration/m_000003/collections.rs b/crates/db/src/migration/m_000003/collections.rs new file mode 100644 index 0000000..2aae68a --- /dev/null +++ b/crates/db/src/migration/m_000003/collections.rs @@ -0,0 +1,69 @@ +use seamantic::schema::sqlite_rowid_alias; + +use sea_orm::sea_query; +use sea_orm::sea_query::{ForeignKeyCreateStatement, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::{binary, binary_null, integer, integer_null, string}; + +use crate::migration::m_000001::FlixInfoCollections; +use crate::migration::m_000003::FlixLibraries; + +#[derive(Iden)] +pub enum FlixCollections { + Table, + Id, + Parent, + Slug, + Library, + Directory, + RelativePosterPath, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixCollections::Table) + .col(sqlite_rowid_alias(FlixCollections::Id)) + .col(integer_null(FlixCollections::Parent)) + .col(string(FlixCollections::Slug)) + .col(integer(FlixCollections::Library)) + .col(binary(FlixCollections::Directory)) + .col(binary_null(FlixCollections::RelativePosterPath)) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_collections-id") + .from_tbl(FlixCollections::Table) + .from_col(FlixCollections::Id) + .to_tbl(FlixInfoCollections::Table) + .to_col(FlixInfoCollections::Id), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_collections-parent") + .from_tbl(FlixCollections::Table) + .from_col(FlixCollections::Parent) + .to_tbl(FlixCollections::Table) + .to_col(FlixCollections::Id), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_collections-library") + .from_tbl(FlixCollections::Table) + .from_col(FlixCollections::Library) + .to_tbl(FlixLibraries::Table) + .to_col(FlixLibraries::Id), + ) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixCollections::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000003/episodes.rs b/crates/db/src/migration/m_000003/episodes.rs new file mode 100644 index 0000000..7a70291 --- /dev/null +++ b/crates/db/src/migration/m_000003/episodes.rs @@ -0,0 +1,73 @@ +use sea_orm::sea_query; +use sea_orm::sea_query::{ForeignKeyCreateStatement, Index, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::{binary, binary_null, integer, string}; + +use crate::migration::m_000001::FlixInfoEpisodes; +use crate::migration::m_000003::FlixLibraries; + +#[derive(Iden)] +pub enum FlixEpisodes { + Table, + Show, + Season, + Episode, + Slug, + Library, + Directory, + RelativeMediaPath, + RelativePosterPath, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixEpisodes::Table) + .col(integer(FlixEpisodes::Show)) + .col(integer(FlixEpisodes::Season)) + .col(integer(FlixEpisodes::Episode)) + .col(string(FlixEpisodes::Slug)) + .col(integer(FlixEpisodes::Library)) + .col(binary(FlixEpisodes::Directory)) + .col(binary(FlixEpisodes::RelativeMediaPath)) + .col(binary_null(FlixEpisodes::RelativePosterPath)) + .primary_key( + Index::create() + .col(FlixEpisodes::Show) + .col(FlixEpisodes::Season) + .col(FlixEpisodes::Episode), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_episodes-show_season_episode") + .from_tbl(FlixEpisodes::Table) + .from_col(FlixEpisodes::Show) + .from_col(FlixEpisodes::Season) + .from_col(FlixEpisodes::Episode) + .to_tbl(FlixInfoEpisodes::Table) + .to_col(FlixInfoEpisodes::Show) + .to_col(FlixInfoEpisodes::Season) + .to_col(FlixInfoEpisodes::Episode), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_episodes-library") + .from_tbl(FlixEpisodes::Table) + .from_col(FlixEpisodes::Library) + .to_tbl(FlixLibraries::Table) + .to_col(FlixLibraries::Id), + ) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixEpisodes::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000003/libraries.rs b/crates/db/src/migration/m_000003/libraries.rs new file mode 100644 index 0000000..6864e5e --- /dev/null +++ b/crates/db/src/migration/m_000003/libraries.rs @@ -0,0 +1,34 @@ +use seamantic::schema::sqlite_rowid_alias; + +use sea_orm::sea_query; +use sea_orm::sea_query::Table; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::binary; + +#[derive(Iden)] +pub enum FlixLibraries { + Table, + Id, + Directory, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixLibraries::Table) + .col(sqlite_rowid_alias(FlixLibraries::Id)) + .col(binary(FlixLibraries::Directory)) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixLibraries::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000003/movies.rs b/crates/db/src/migration/m_000003/movies.rs new file mode 100644 index 0000000..59f5264 --- /dev/null +++ b/crates/db/src/migration/m_000003/movies.rs @@ -0,0 +1,71 @@ +use seamantic::schema::sqlite_rowid_alias; + +use sea_orm::sea_query; +use sea_orm::sea_query::{ForeignKeyCreateStatement, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::{binary, binary_null, integer, integer_null, string}; + +use crate::migration::m_000001::FlixInfoMovies; +use crate::migration::m_000003::{FlixCollections, FlixLibraries}; + +#[derive(Iden)] +pub enum FlixMovies { + Table, + Id, + Parent, + Slug, + Library, + Directory, + RelativeMediaPath, + RelativePosterPath, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixMovies::Table) + .col(sqlite_rowid_alias(FlixMovies::Id)) + .col(integer_null(FlixMovies::Parent)) + .col(string(FlixMovies::Slug)) + .col(integer(FlixMovies::Library)) + .col(binary(FlixMovies::Directory)) + .col(binary(FlixMovies::RelativeMediaPath)) + .col(binary_null(FlixMovies::RelativePosterPath)) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_movies-id") + .from_tbl(FlixMovies::Table) + .from_col(FlixMovies::Id) + .to_tbl(FlixInfoMovies::Table) + .to_col(FlixInfoMovies::Id), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_movies-parent") + .from_tbl(FlixMovies::Table) + .from_col(FlixMovies::Parent) + .to_tbl(FlixCollections::Table) + .to_col(FlixCollections::Id), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_movies-library") + .from_tbl(FlixMovies::Table) + .from_col(FlixMovies::Library) + .to_tbl(FlixLibraries::Table) + .to_col(FlixLibraries::Id), + ) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixMovies::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000003/seasons.rs b/crates/db/src/migration/m_000003/seasons.rs new file mode 100644 index 0000000..be51624 --- /dev/null +++ b/crates/db/src/migration/m_000003/seasons.rs @@ -0,0 +1,66 @@ +use sea_orm::sea_query; +use sea_orm::sea_query::{ForeignKeyCreateStatement, Index, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::{binary, binary_null, integer, string}; + +use crate::migration::m_000001::FlixInfoSeasons; +use crate::migration::m_000003::FlixLibraries; + +#[derive(Iden)] +pub enum FlixSeasons { + Table, + Show, + Season, + Slug, + Library, + Directory, + RelativePosterPath, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixSeasons::Table) + .col(integer(FlixSeasons::Show)) + .col(integer(FlixSeasons::Season)) + .col(string(FlixSeasons::Slug)) + .col(integer(FlixSeasons::Library)) + .col(binary(FlixSeasons::Directory)) + .col(binary_null(FlixSeasons::RelativePosterPath)) + .primary_key( + Index::create() + .col(FlixSeasons::Show) + .col(FlixSeasons::Season), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_seasons-show_season") + .from_tbl(FlixSeasons::Table) + .from_col(FlixSeasons::Show) + .from_col(FlixSeasons::Season) + .to_tbl(FlixInfoSeasons::Table) + .to_col(FlixInfoSeasons::Show) + .to_col(FlixInfoSeasons::Season), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_seasons-library") + .from_tbl(FlixSeasons::Table) + .from_col(FlixSeasons::Library) + .to_tbl(FlixLibraries::Table) + .to_col(FlixLibraries::Id), + ) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixSeasons::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000003/shows.rs b/crates/db/src/migration/m_000003/shows.rs new file mode 100644 index 0000000..65385a9 --- /dev/null +++ b/crates/db/src/migration/m_000003/shows.rs @@ -0,0 +1,69 @@ +use seamantic::schema::sqlite_rowid_alias; + +use sea_orm::sea_query; +use sea_orm::sea_query::{ForeignKeyCreateStatement, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::{binary, binary_null, integer, integer_null, string}; + +use crate::migration::m_000001::FlixInfoShows; +use crate::migration::m_000003::{FlixCollections, FlixLibraries}; + +#[derive(Iden)] +pub enum FlixShows { + Table, + Id, + Parent, + Slug, + Library, + Directory, + RelativePosterPath, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixShows::Table) + .col(sqlite_rowid_alias(FlixShows::Id)) + .col(integer_null(FlixShows::Parent)) + .col(string(FlixShows::Slug)) + .col(integer(FlixShows::Library)) + .col(binary(FlixShows::Directory)) + .col(binary_null(FlixShows::RelativePosterPath)) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_shows-id") + .from_tbl(FlixShows::Table) + .from_col(FlixShows::Id) + .to_tbl(FlixInfoShows::Table) + .to_col(FlixInfoShows::Id), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_shows-parent") + .from_tbl(FlixShows::Table) + .from_col(FlixShows::Parent) + .to_tbl(FlixCollections::Table) + .to_col(FlixCollections::Id), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_shows-library") + .from_tbl(FlixShows::Table) + .from_col(FlixShows::Library) + .to_tbl(FlixLibraries::Table) + .to_col(FlixLibraries::Id), + ) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixShows::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000004.rs b/crates/db/src/migration/m_000004.rs new file mode 100644 index 0000000..db96196 --- /dev/null +++ b/crates/db/src/migration/m_000004.rs @@ -0,0 +1,47 @@ +//! Adds entity/watched tables: +//! - Collections +//! - Movies +//! - Shows +//! - Seasons +//! - Episodes + +use sea_orm::{DbErr, DeriveMigrationName}; +use sea_orm_migration::async_trait; +use sea_orm_migration::{MigrationTrait, SchemaManager}; + +mod collections; +mod episodes; +mod movies; +mod seasons; +mod shows; + +#[allow(unused_imports)] +pub use episodes::FlixWatchedEpisodes; +#[allow(unused_imports)] +pub use movies::FlixWatchedMovies; + +#[derive(DeriveMigrationName)] +pub(super) struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + episodes::up(manager).await?; + seasons::up(manager).await?; + shows::up(manager).await?; + movies::up(manager).await?; + collections::up(manager).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + collections::down(manager).await?; + movies::down(manager).await?; + shows::down(manager).await?; + seasons::down(manager).await?; + episodes::down(manager).await?; + + Ok(()) + } +} diff --git a/crates/db/src/migration/m_000004/collections.rs b/crates/db/src/migration/m_000004/collections.rs new file mode 100644 index 0000000..5b0ab16 --- /dev/null +++ b/crates/db/src/migration/m_000004/collections.rs @@ -0,0 +1,98 @@ +use sea_orm::DbErr; +use sea_orm::{ConnectionTrait, DbBackend, Statement}; +use sea_orm_migration::SchemaManager; + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(Statement::from_string( + DbBackend::Sqlite, + r#" + CREATE VIEW flix_watched_collections AS + WITH RECURSIVE + watched_items AS ( + SELECT + w.id, + w.user_id, + w.watched_date, + 'movie' AS type + FROM flix_watched_movies w + + UNION ALL + + SELECT + w.id, + w.user_id, + w.watched_date, + 'show' AS type + FROM flix_watched_shows w + ), + collection_items AS ( + SELECT + m.parent, + m.id, + 'movie' AS type + FROM flix_movies m + WHERE m.parent IS NOT NULL + + UNION ALL + + SELECT + s.parent, + s.id, + 'show' AS type + FROM flix_shows s + WHERE s.parent IS NOT NULL + + UNION ALL + + SELECT + c.parent, + ci.id, + ci.type + FROM collection_items ci + JOIN flix_collections c + ON c.id = ci.parent + ) + SELECT + ci.parent AS id, + wi.user_id, + MAX(wi.watched_date) AS watched_date + FROM collection_items ci + JOIN watched_items wi + ON wi.id = ci.id + AND wi.type = ci.type + WHERE NOT EXISTS ( + SELECT 1 + FROM collection_items ci2 + WHERE ci2.parent = ci.parent + AND NOT EXISTS ( + SELECT 1 + FROM watched_items wi2 + WHERE wi2.id = ci2.id + AND wi2.type = ci2.type + AND wi2.user_id = wi.user_id + ) + ) + GROUP BY ci.parent, wi.user_id; + "#, + )) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(Statement::from_string( + DbBackend::Sqlite, + r#" + DROP VIEW flix_watched_collections + ; + "#, + )) + .await?; + + Ok(()) +} diff --git a/crates/db/src/migration/m_000004/episodes.rs b/crates/db/src/migration/m_000004/episodes.rs new file mode 100644 index 0000000..2ce3dd9 --- /dev/null +++ b/crates/db/src/migration/m_000004/episodes.rs @@ -0,0 +1,60 @@ +use sea_orm::sea_query; +use sea_orm::sea_query::{ForeignKeyCreateStatement, Index, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::{date, integer}; + +use crate::migration::m_000001::FlixInfoEpisodes; + +#[derive(Iden)] +pub enum FlixWatchedEpisodes { + Table, + Show, + Season, + Episode, + UserId, + WatchedDate, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixWatchedEpisodes::Table) + .col(integer(FlixWatchedEpisodes::Show)) + .col(integer(FlixWatchedEpisodes::Season)) + .col(integer(FlixWatchedEpisodes::Episode)) + .col(integer(FlixWatchedEpisodes::UserId)) + .col(date(FlixWatchedEpisodes::WatchedDate)) + .primary_key( + Index::create() + .col(FlixWatchedEpisodes::Show) + .col(FlixWatchedEpisodes::Season) + .col(FlixWatchedEpisodes::Episode) + .col(FlixWatchedEpisodes::UserId), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_watched_episodes-show_season_episode") + .from_tbl(FlixWatchedEpisodes::Table) + .from_tbl(FlixWatchedEpisodes::Table) + .from_col(FlixWatchedEpisodes::Show) + .from_col(FlixWatchedEpisodes::Season) + .from_col(FlixWatchedEpisodes::Episode) + .to_tbl(FlixInfoEpisodes::Table) + .to_col(FlixInfoEpisodes::Show) + .to_col(FlixInfoEpisodes::Season) + .to_col(FlixInfoEpisodes::Episode), + ) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixWatchedEpisodes::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000004/movies.rs b/crates/db/src/migration/m_000004/movies.rs new file mode 100644 index 0000000..6f4175e --- /dev/null +++ b/crates/db/src/migration/m_000004/movies.rs @@ -0,0 +1,49 @@ +use sea_orm::sea_query; +use sea_orm::sea_query::{ForeignKeyCreateStatement, Index, Table}; +use sea_orm::{DbErr, Iden}; +use sea_orm_migration::SchemaManager; +use sea_orm_migration::schema::{date, integer}; + +use crate::migration::m_000001::FlixInfoMovies; + +#[derive(Iden)] +pub enum FlixWatchedMovies { + Table, + Id, + UserId, + WatchedDate, +} + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlixWatchedMovies::Table) + .col(integer(FlixWatchedMovies::Id)) + .col(integer(FlixWatchedMovies::UserId)) + .col(date(FlixWatchedMovies::WatchedDate)) + .primary_key( + Index::create() + .col(FlixWatchedMovies::Id) + .col(FlixWatchedMovies::UserId), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-flix_watched_movies-id") + .from_tbl(FlixWatchedMovies::Table) + .from_col(FlixWatchedMovies::Id) + .to_tbl(FlixInfoMovies::Table) + .to_col(FlixInfoMovies::Id), + ) + .to_owned(), + ) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FlixWatchedMovies::Table).to_owned()) + .await +} diff --git a/crates/db/src/migration/m_000004/seasons.rs b/crates/db/src/migration/m_000004/seasons.rs new file mode 100644 index 0000000..08bb07f --- /dev/null +++ b/crates/db/src/migration/m_000004/seasons.rs @@ -0,0 +1,54 @@ +use sea_orm::DbErr; +use sea_orm::{ConnectionTrait, DbBackend, Statement}; +use sea_orm_migration::SchemaManager; + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(Statement::from_string( + DbBackend::Sqlite, + r#" + CREATE VIEW flix_watched_seasons AS + SELECT + w.show, + w.season, + w.user_id, + MAX(w.watched_date) AS watched_date + FROM flix_watched_episodes w + WHERE NOT EXISTS ( + SELECT 1 + FROM flix_episodes e + WHERE e.show = w.show + AND e.season = w.season + AND NOT EXISTS ( + SELECT 1 + FROM flix_watched_episodes wc + WHERE wc.show = e.show + AND wc.season = e.season + AND wc.episode = e.episode + AND wc.user_id = w.user_id + ) + ) + GROUP BY w.show, w.season, w.user_id + ; + "#, + )) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(Statement::from_string( + DbBackend::Sqlite, + r#" + DROP VIEW flix_watched_seasons + ; + "#, + )) + .await?; + + Ok(()) +} diff --git a/crates/db/src/migration/m_000004/shows.rs b/crates/db/src/migration/m_000004/shows.rs new file mode 100644 index 0000000..08afa89 --- /dev/null +++ b/crates/db/src/migration/m_000004/shows.rs @@ -0,0 +1,51 @@ +use sea_orm::DbErr; +use sea_orm::{ConnectionTrait, DbBackend, Statement}; +use sea_orm_migration::SchemaManager; + +pub async fn up(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(Statement::from_string( + DbBackend::Sqlite, + r#" + CREATE VIEW flix_watched_shows AS + SELECT + w.show as id, + w.user_id, + MAX(w.watched_date) AS watched_date + FROM flix_watched_seasons w + WHERE NOT EXISTS ( + SELECT 1 + FROM flix_seasons s + WHERE s.show = w.show + AND NOT EXISTS ( + SELECT 1 + FROM flix_watched_seasons wc + WHERE wc.show = s.show + AND wc.season = s.season + AND wc.user_id = w.user_id + ) + ) + GROUP BY w.show, w.user_id + ; + "#, + )) + .await?; + + Ok(()) +} + +pub async fn down(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(Statement::from_string( + DbBackend::Sqlite, + r#" + DROP VIEW flix_watched_shows + ; + "#, + )) + .await?; + + Ok(()) +} diff --git a/crates/db/src/migration/mod.rs b/crates/db/src/migration/mod.rs new file mode 100644 index 0000000..f4e50d7 --- /dev/null +++ b/crates/db/src/migration/mod.rs @@ -0,0 +1,9 @@ +//! Migrations for maintaining the database schema + +seamantic::migrations! { + "seaql_migrations_flix"; + m_000001, + m_000002, + m_000003, + m_000004, +} diff --git a/crates/flix/Cargo.toml b/crates/flix/Cargo.toml index 44f84b3..915764e 100644 --- a/crates/flix/Cargo.toml +++ b/crates/flix/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "flix" -version = "0.0.8" +version = "0.0.9" categories = [] -description = "Types for storing persistent data about media" +description = "Mechanisms for interacting with flix media" repository = "https://github.com/QuantumShade/flix" authors.workspace = true @@ -11,16 +11,22 @@ edition.workspace = true license-file.workspace = true rust-version.workspace = true +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + [lints] workspace = true [features] default = [] -tmdb = ["dep:flix-tmdb"] +fs = ["dep:flix-fs"] +serde = ["flix-model/serde"] +tmdb = ["dep:flix-tmdb", "flix-db/tmdb"] [dependencies] -flix-tmdb = { workspace = true, optional = true } +flix-db = { workspace = true } +flix-model = { workspace = true } -chrono = { workspace = true, features = ["serde"] } -serde = { workspace = true, features = ["std", "derive"] } -thiserror = { workspace = true } +flix-fs = { workspace = true, optional = true } +flix-tmdb = { workspace = true, optional = true } diff --git a/crates/flix/README.md b/crates/flix/README.md index 1779eae..3d73740 100644 --- a/crates/flix/README.md +++ b/crates/flix/README.md @@ -2,4 +2,4 @@ [![Crates Version](https://img.shields.io/crates/v/flix.svg)](https://crates.io/crates/flix) -A library providing types for storing persistent data about media +A library for interacting with flix media diff --git a/crates/flix/src/lib.rs b/crates/flix/src/lib.rs index 56c7823..bb29afd 100644 --- a/crates/flix/src/lib.rs +++ b/crates/flix/src/lib.rs @@ -1,4 +1,13 @@ -//! flix provides types for storing persistent data about media +//! flix provides mechanisms for interacting with flix media -/// flix types -pub mod model; +#![cfg_attr(docsrs, feature(doc_cfg))] + +pub use flix_db as db; +pub use flix_model as model; + +#[cfg(feature = "fs")] +#[cfg_attr(docsrs, doc(cfg(feature = "fs")))] +pub use flix_fs as fs; +#[cfg(feature = "tmdb")] +#[cfg_attr(docsrs, doc(cfg(feature = "tmdb")))] +pub use flix_tmdb as tmdb; diff --git a/crates/flix/src/model/collection.rs b/crates/flix/src/model/collection.rs deleted file mode 100644 index f8923b1..0000000 --- a/crates/flix/src/model/collection.rs +++ /dev/null @@ -1,30 +0,0 @@ -#[cfg(feature = "tmdb")] -use flix_tmdb::model::CollectionId; - -/// A Collection container -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct Collection { - /// Generic collection data - pub collection: GenericCollection, - - /// TMDB collection data - #[cfg(feature = "tmdb")] - pub tmdb: Option, -} - -/// The generic collection data -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct GenericCollection { - /// The collection's title - pub title: String, - /// The collection's overview - pub overview: String, -} - -/// The TMDB collection data -#[cfg(feature = "tmdb")] -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct TmdbCollection { - /// The collection's TMDB ID - pub id: CollectionId, -} diff --git a/crates/flix/src/model/episode.rs b/crates/flix/src/model/episode.rs deleted file mode 100644 index ac0dc53..0000000 --- a/crates/flix/src/model/episode.rs +++ /dev/null @@ -1,73 +0,0 @@ -#[cfg(feature = "tmdb")] -use flix_tmdb::model::ShowId; - -use chrono::NaiveDate; - -/// An Episode container -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct Episode { - /// The generic episode data - pub episode: GenericEpisode, - - /// The TMDB episode data - #[cfg(feature = "tmdb")] - pub tmdb: Option, -} - -/// A wrapper for handling single and multi-episode entries -#[derive(Debug, serde::Serialize, serde::Deserialize)] -#[serde(untagged)] -pub enum EpisodeNumber { - /// The entry contains a single episode - Single { - /// The episode's number - number: u32, - }, - /// The entry contains multiple episodes - Multiple { - /// The list of episode numbers - numbers: Vec, - }, -} - -impl EpisodeNumber { - /// Get the primary episode number of this episode - pub fn primary_episode_number(&self) -> Option { - match self { - EpisodeNumber::Single { number } => Some(*number), - EpisodeNumber::Multiple { numbers } => numbers.first().copied(), - } - } - - /// Get additional episode numbers of this episode - pub fn additional_episode_numbers(&self) -> Vec { - match self { - EpisodeNumber::Single { number: _ } => vec![], - EpisodeNumber::Multiple { numbers } => numbers.iter().skip(1).copied().collect(), - } - } -} - -/// The generic episode data -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct GenericEpisode { - /// The episode's number(s) - #[serde(flatten)] - pub number: EpisodeNumber, - /// The episode's season's number - pub season: u32, - /// The episode's title - pub title: String, - /// The episode's overview - pub overview: String, - /// The episode's air date - pub air_date: NaiveDate, -} - -/// The TMDB show data -#[cfg(feature = "tmdb")] -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct TmdbEpisode { - /// The episodes's show's TMDB ID - pub show_id: ShowId, -} diff --git a/crates/flix/src/model/mod.rs b/crates/flix/src/model/mod.rs deleted file mode 100644 index cf4a661..0000000 --- a/crates/flix/src/model/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -mod collection; -mod episode; -mod movie; -mod season; -mod show; -mod verse; - -pub use collection::*; -pub use episode::*; -pub use movie::*; -pub use season::*; -pub use show::*; -pub use verse::*; diff --git a/crates/flix/src/model/movie.rs b/crates/flix/src/model/movie.rs deleted file mode 100644 index b9bc5d6..0000000 --- a/crates/flix/src/model/movie.rs +++ /dev/null @@ -1,38 +0,0 @@ -#[cfg(feature = "tmdb")] -use flix_tmdb::model::{MovieGenreId, MovieId}; - -use chrono::NaiveDate; - -/// A Movie container -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct Movie { - /// The generic movie data - pub movie: GenericMovie, - - /// The TMDB movie data - #[cfg(feature = "tmdb")] - pub tmdb: Option, -} - -/// The generic movie data -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct GenericMovie { - /// The movie's title - pub title: String, - /// The movie's overview - pub overview: String, - /// The movie's genres - pub genres: Vec, - /// The movie's release date - pub release_date: NaiveDate, -} - -/// The TMDB movie data -#[cfg(feature = "tmdb")] -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct TmdbMovie { - /// The movie's TMDB ID - pub id: MovieId, - /// The list of genre TMDB IDs that the movie is associated with - pub genres: Vec, -} diff --git a/crates/flix/src/model/season.rs b/crates/flix/src/model/season.rs deleted file mode 100644 index 56c614d..0000000 --- a/crates/flix/src/model/season.rs +++ /dev/null @@ -1,36 +0,0 @@ -#[cfg(feature = "tmdb")] -use flix_tmdb::model::ShowId; - -use chrono::NaiveDate; - -/// A Season container -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct Season { - /// The generic season data - pub season: GenericSeason, - - /// The TMDB season data - #[cfg(feature = "tmdb")] - pub tmdb: Option, -} - -/// The generic season data -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct GenericSeason { - /// The season's number - pub number: u32, - /// The season's title - pub title: String, - /// The season's overview - pub overview: String, - /// The season's air date - pub air_date: NaiveDate, -} - -/// The TMDB show data -#[cfg(feature = "tmdb")] -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct TmdbSeason { - /// The season's show's TMDB ID - pub show_id: ShowId, -} diff --git a/crates/flix/src/model/show.rs b/crates/flix/src/model/show.rs deleted file mode 100644 index 56e909e..0000000 --- a/crates/flix/src/model/show.rs +++ /dev/null @@ -1,38 +0,0 @@ -#[cfg(feature = "tmdb")] -use flix_tmdb::model::{ShowGenreId, ShowId}; - -use chrono::NaiveDate; - -/// A Show container -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct Show { - /// The generic show data - pub show: GenericShow, - - /// The TMDB show data - #[cfg(feature = "tmdb")] - pub tmdb: Option, -} - -/// The generic show data -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct GenericShow { - /// The show's title - pub title: String, - /// The show's overview - pub overview: String, - /// The show's genres - pub genres: Vec, - /// The show's air date - pub air_date: NaiveDate, -} - -/// The TMDB show data -#[cfg(feature = "tmdb")] -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct TmdbShow { - /// The show's TMDB ID - pub id: ShowId, - /// The list of genre TMDB IDs that the movie is associated with - pub genres: Vec, -} diff --git a/crates/flix/src/model/verse.rs b/crates/flix/src/model/verse.rs deleted file mode 100644 index 66e3fd0..0000000 --- a/crates/flix/src/model/verse.rs +++ /dev/null @@ -1,40 +0,0 @@ -#[cfg(feature = "tmdb")] -use flix_tmdb::model::{CollectionId, MovieId, ShowId}; - -/// A Verse container -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct Verse { - /// The generic verse data - pub verse: GenericVerse, - - /// The TMDB verse data - #[cfg(feature = "tmdb")] - pub tmdb: Option, -} - -/// The generic verse data -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct GenericVerse { - /// The verse's title - pub title: String, - /// The verse's overview - pub overview: String, -} - -/// The TMDB verse data -#[cfg(feature = "tmdb")] -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct TmdbVerse { - /// The list of collection TMDB IDs in the verse - #[serde(default)] - pub collections: Vec, - /// The list of movie TMDB IDs in the verse - #[serde(default)] - pub movies: Vec, - /// The list of show TMDB IDs in the verse - #[serde(default)] - pub shows: Vec, - /// The list of sub-verse names - #[serde(default)] - pub verses: Vec, -} diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml new file mode 100644 index 0000000..2b70582 --- /dev/null +++ b/crates/fs/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "flix-fs" +version = "0.0.9" + +categories = [] +description = "Filesystem scanner for flix media" +repository = "https://github.com/QuantumShade/flix" + +authors.workspace = true +edition.workspace = true +license-file.workspace = true +rust-version.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[dependencies] +flix-model = { workspace = true } + +async-stream = { workspace = true } +regex = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-stream = { workspace = true, features = ["fs"] } diff --git a/crates/fs/README.md b/crates/fs/README.md new file mode 100644 index 0000000..afabdc5 --- /dev/null +++ b/crates/fs/README.md @@ -0,0 +1,5 @@ +# flix-fs + +[![Crates Version](https://img.shields.io/crates/v/flix-fs.svg)](https://crates.io/crates/flix-fs) + +A library providing filesystem scanners for flix media diff --git a/crates/fs/src/error.rs b/crates/fs/src/error.rs new file mode 100644 index 0000000..8f5989e --- /dev/null +++ b/crates/fs/src/error.rs @@ -0,0 +1,39 @@ +use std::io; + +/// The error type for filesystem scanning operations. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// fs::read_dir failed + #[error("fs::read_dir: {0}")] + ReadDir(io::Error), + /// fs::read_dir::next_entry failed + #[error("fs::read_dir::next_entry: {0}")] + ReadDirEntry(io::Error), + /// fs::read_dir::file_type failed + #[error("fs::read_dir::file_type: {0}")] + FileType(io::Error), + + /// There is an unexpected file in the directory + #[error("unexpected file")] + UnexpectedFile, + /// There is an unexpected folder in the directory + #[error("unexpected folder")] + UnexpectedFolder, + /// There is an unexpected non-file item in the directory + #[error("unexpected non-file")] + UnexpectedNonFile, + + /// There are multiple media files in the directory + #[error("duplicate media file")] + DuplicateMediaFile, + /// There are multiple poster files in the directory + #[error("duplicate poster file")] + DuplicatePosterFile, + + /// The directory contains incomplete flix media + #[error("incomplete")] + Incomplete, + /// Some data is inconsistent with the folder structure + #[error("inconsistent")] + Inconsistent, +} diff --git a/crates/fs/src/item.rs b/crates/fs/src/item.rs new file mode 100644 index 0000000..2f75868 --- /dev/null +++ b/crates/fs/src/item.rs @@ -0,0 +1,23 @@ +use std::path::PathBuf; + +use crate::Error; + +/// An item returned by scanner streams +#[derive(Debug)] +pub struct Item { + /// The path of the item + pub path: PathBuf, + /// The event relating to the item + pub event: Result, +} + +impl Item { + /// Helper function for mapping the inner event [Result] + #[inline] + pub fn map U>(self, op: F) -> Item { + Item { + path: self.path, + event: self.event.map(op), + } + } +} diff --git a/crates/fs/src/lib.rs b/crates/fs/src/lib.rs new file mode 100644 index 0000000..cd412d6 --- /dev/null +++ b/crates/fs/src/lib.rs @@ -0,0 +1,14 @@ +//! flix-fs provides filesystem scanners for flix media + +#![cfg_attr(docsrs, feature(doc_cfg))] + +mod macros; + +mod error; + +pub use error::Error; + +mod item; +pub use item::Item; + +pub mod scanner; diff --git a/crates/fs/src/macros.rs b/crates/fs/src/macros.rs new file mode 100644 index 0000000..09feef5 --- /dev/null +++ b/crates/fs/src/macros.rs @@ -0,0 +1,13 @@ +macro_rules! is_media_extension { + () => { + Some("mp4" | "mkv") + }; +} +pub(super) use is_media_extension; + +macro_rules! is_image_extension { + () => { + Some("png" | "jpg") + }; +} +pub(super) use is_image_extension; diff --git a/crates/fs/src/scanner/collection.rs b/crates/fs/src/scanner/collection.rs new file mode 100644 index 0000000..a854825 --- /dev/null +++ b/crates/fs/src/scanner/collection.rs @@ -0,0 +1,282 @@ +//! The collection scanner will scan a folder and its children + +use core::pin::Pin; +use std::ffi::OsStr; +use std::path::Path; + +use flix_model::id::{CollectionId, MovieId, ShowId}; +use flix_model::numbers::{EpisodeNumbers, SeasonNumber}; + +use async_stream::stream; +use tokio::fs; +use tokio_stream::Stream; +use tokio_stream::wrappers::ReadDirStream; + +use crate::Error; +use crate::macros::is_image_extension; +use crate::scanner::{generic, movie, show}; + +/// A collection item +pub type Item = crate::Item; + +/// The scanner for collections +pub enum Scanner { + /// A scanned collection + Collection { + /// The ID of the parent collection (if any) + parent: Option, + /// The ID of the collection + id: CollectionId, + /// The file name of the poster file + poster_file_name: Option, + }, + + /// A scanned movie + Movie { + /// The ID of the parent collection (if any) + parent: Option, + /// The ID of the movie + id: MovieId, + /// The file name of the media file + media_file_name: String, + /// The file name of the poster file + poster_file_name: Option, + }, + + /// A scanned show + Show { + /// The ID of the parent collection (if any) + parent: Option, + /// The ID of the show this episode belongs to + id: ShowId, + /// The file name of the poster file + poster_file_name: Option, + }, + /// A scanned episode + Season { + /// The ID of the show this episode belongs to + show: ShowId, + /// The season this episode belongs to + number: SeasonNumber, + /// The file name of the poster file + poster_file_name: Option, + }, + /// A scanned episode + Episode { + /// The ID of the show this episode belongs to + show: ShowId, + /// The season this episode belongs to + season: SeasonNumber, + /// The number(s) of this episode + number: EpisodeNumbers, + /// The file name of the media file + media_file_name: String, + /// The file name of the poster file + poster_file_name: Option, + }, +} + +impl From for Scanner { + fn from(value: movie::Scanner) -> Self { + match value { + movie::Scanner::Movie { + parent, + id, + media_file_name, + poster_file_name, + } => Self::Movie { + parent, + id, + media_file_name, + poster_file_name, + }, + } + } +} + +impl From for Scanner { + fn from(value: show::Scanner) -> Self { + match value { + show::Scanner::Show { + parent, + id, + poster_file_name, + } => Self::Show { + parent, + id, + poster_file_name, + }, + show::Scanner::Season { + show, + number, + poster_file_name, + } => Self::Season { + show, + number, + poster_file_name, + }, + show::Scanner::Episode { + show, + season, + number, + media_file_name, + poster_file_name, + } => Self::Episode { + show, + season, + number, + media_file_name, + poster_file_name, + }, + } + } +} + +impl From for Scanner { + fn from(value: generic::Scanner) -> Self { + match value { + generic::Scanner::Collection { + parent, + id, + poster_file_name, + } => Self::Collection { + parent, + id, + poster_file_name, + }, + generic::Scanner::Movie { + parent, + id, + media_file_name, + poster_file_name, + } => Self::Movie { + parent, + id, + media_file_name, + poster_file_name, + }, + generic::Scanner::Show { + parent, + id, + poster_file_name, + } => Self::Show { + parent, + id, + poster_file_name, + }, + generic::Scanner::Season { + show, + number, + poster_file_name, + } => Self::Season { + show, + number, + poster_file_name, + }, + generic::Scanner::Episode { + show, + season, + number, + media_file_name, + poster_file_name, + } => Self::Episode { + show, + season, + number, + media_file_name, + poster_file_name, + }, + } + } +} + +impl Scanner { + /// Scan a folder for a collection + pub fn scan_collection( + path: &Path, + parent: Option, + id: CollectionId, + ) -> Pin>> { + Box::pin(stream!({ + let dirs = match fs::read_dir(path).await { + Ok(dirs) => dirs, + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::ReadDir(err)), + }; + return; + } + }; + + let mut poster_file_name = None; + let mut subdirs_to_scan = Vec::new(); + + for await dir in ReadDirStream::new(dirs) { + match dir { + Ok(dir) => { + let filetype = match dir.file_type().await { + Ok(filetype) => filetype, + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::FileType(err)), + }; + continue; + } + }; + + let path = dir.path(); + if filetype.is_dir() { + subdirs_to_scan.push(path); + continue; + } + + match path.extension().and_then(OsStr::to_str) { + is_image_extension!() => { + if poster_file_name.is_some() { + yield Item { + path: path.to_owned(), + event: Err(Error::DuplicatePosterFile), + }; + continue; + } + poster_file_name = path + .file_name() + .and_then(|s| s.to_str()) + .map(ToOwned::to_owned); + } + Some(_) | None => { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFile), + }; + } + } + } + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::ReadDirEntry(err)), + } + } + } + } + + yield Item { + path: path.to_owned(), + event: Ok(Self::Collection { + parent, + id, + poster_file_name, + }), + }; + + for subdir in subdirs_to_scan { + for await event in generic::Scanner::scan_detect_folder(&subdir, Some(id)) { + yield event.map(|e| e.into()); + } + } + })) + } +} diff --git a/crates/fs/src/scanner/episode.rs b/crates/fs/src/scanner/episode.rs new file mode 100644 index 0000000..1112a6e --- /dev/null +++ b/crates/fs/src/scanner/episode.rs @@ -0,0 +1,147 @@ +//! The episode scanner will scan a folder and exit + +use std::ffi::OsStr; +use std::path::Path; + +use flix_model::id::ShowId; +use flix_model::numbers::{EpisodeNumbers, SeasonNumber}; + +use async_stream::stream; +use tokio::fs; +use tokio_stream::Stream; +use tokio_stream::wrappers::ReadDirStream; + +use crate::Error; +use crate::macros::{is_image_extension, is_media_extension}; + +/// An episode item +pub type Item = crate::Item; + +/// The scanner for epispdes +pub enum Scanner { + /// A scanned episode + Episode { + /// The ID of the show this episode belongs to + show: ShowId, + /// The season this episode belongs to + season: SeasonNumber, + /// The number(s) of this episode + number: EpisodeNumbers, + /// The file name of the media file + media_file_name: String, + /// The file name of the poster file + poster_file_name: Option, + }, +} + +impl Scanner { + /// Scan a folder for an episode + pub fn scan_episode( + path: &Path, + show: ShowId, + season: SeasonNumber, + number: EpisodeNumbers, + ) -> impl Stream { + stream!({ + let dirs = match fs::read_dir(path).await { + Ok(dirs) => dirs, + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::ReadDir(err)), + }; + return; + } + }; + + let mut media_file_name = None; + let mut poster_file_name = None; + + for await dir in ReadDirStream::new(dirs) { + match dir { + Ok(dir) => { + let filetype = match dir.file_type().await { + Ok(filetype) => filetype, + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::FileType(err)), + }; + continue; + } + }; + if !filetype.is_file() { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedNonFile), + }; + continue; + } + + let path = dir.path(); + match path.extension().and_then(OsStr::to_str) { + is_media_extension!() => { + if media_file_name.is_some() { + yield Item { + path: path.to_owned(), + event: Err(Error::DuplicateMediaFile), + }; + continue; + } + media_file_name = path + .file_name() + .and_then(|s| s.to_str()) + .map(ToOwned::to_owned); + continue; + } + is_image_extension!() => { + if poster_file_name.is_some() { + yield Item { + path: path.to_owned(), + event: Err(Error::DuplicatePosterFile), + }; + continue; + } + poster_file_name = path + .file_name() + .and_then(|s| s.to_str()) + .map(ToOwned::to_owned); + } + Some(_) | None => { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFile), + }; + } + } + } + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::ReadDirEntry(err)), + } + } + } + } + + let Some(media_file_name) = media_file_name else { + yield Item { + path: path.to_owned(), + event: Err(Error::Incomplete), + }; + return; + }; + + yield Item { + path: path.to_owned(), + event: Ok(Self::Episode { + show, + season, + number, + media_file_name, + poster_file_name, + }), + }; + }) + } +} diff --git a/crates/fs/src/scanner/generic.rs b/crates/fs/src/scanner/generic.rs new file mode 100644 index 0000000..f4e7b22 --- /dev/null +++ b/crates/fs/src/scanner/generic.rs @@ -0,0 +1,331 @@ +//! The generic scanner will scan a directory and automatically +//! detect the type of media, deferring to the correct scanner. + +use std::ffi::OsStr; +use std::path::Path; +use std::sync::OnceLock; + +use flix_model::id::{CollectionId, MovieId, RawId, ShowId}; +use flix_model::numbers::{EpisodeNumbers, SeasonNumber}; + +use async_stream::stream; +use regex::Regex; +use tokio::fs; +use tokio_stream::Stream; +use tokio_stream::wrappers::ReadDirStream; + +use crate::Error; +use crate::scanner::{collection, movie, show}; + +static MEDIA_FOLDER_REGEX: OnceLock = OnceLock::new(); +static SEASON_FOLDER_REGEX: OnceLock = OnceLock::new(); + +/// A collection item +pub type Item = crate::Item; + +/// The scanner for collections +pub enum Scanner { + /// A scanned collection + Collection { + /// The ID of the parent collection (if any) + parent: Option, + /// The ID of the collection + id: CollectionId, + /// The file name of the poster file + poster_file_name: Option, + }, + + /// A scanned movie + Movie { + /// The ID of the parent collection (if any) + parent: Option, + /// The ID of the movie + id: MovieId, + /// The file name of the media file + media_file_name: String, + /// The file name of the poster file + poster_file_name: Option, + }, + + /// A scanned show + Show { + /// The ID of the parent collection (if any) + parent: Option, + /// The ID of the show this episode belongs to + id: ShowId, + /// The file name of the poster file + poster_file_name: Option, + }, + /// A scanned episode + Season { + /// The ID of the show this episode belongs to + show: ShowId, + /// The season this episode belongs to + number: SeasonNumber, + /// The file name of the poster file + poster_file_name: Option, + }, + /// A scanned episode + Episode { + /// The ID of the show this episode belongs to + show: ShowId, + /// The season this episode belongs to + season: SeasonNumber, + /// The number(s) of this episode + number: EpisodeNumbers, + /// The file name of the media file + media_file_name: String, + /// The file name of the poster file + poster_file_name: Option, + }, +} + +impl From for Scanner { + fn from(value: collection::Scanner) -> Self { + match value { + collection::Scanner::Collection { + parent, + id, + poster_file_name, + } => Self::Collection { + parent, + id, + poster_file_name, + }, + collection::Scanner::Movie { + parent, + id, + media_file_name, + poster_file_name, + } => Self::Movie { + parent, + id, + media_file_name, + poster_file_name, + }, + collection::Scanner::Show { + parent, + id, + poster_file_name, + } => Self::Show { + parent, + id, + poster_file_name, + }, + collection::Scanner::Season { + show, + number, + poster_file_name, + } => Self::Season { + show, + number, + poster_file_name, + }, + collection::Scanner::Episode { + show, + season, + number, + media_file_name, + poster_file_name, + } => Self::Episode { + show, + season, + number, + media_file_name, + poster_file_name, + }, + } + } +} + +impl From for Scanner { + fn from(value: movie::Scanner) -> Self { + match value { + movie::Scanner::Movie { + parent, + id, + media_file_name, + poster_file_name, + } => Self::Movie { + parent, + id, + media_file_name, + poster_file_name, + }, + } + } +} + +impl From for Scanner { + fn from(value: show::Scanner) -> Self { + match value { + show::Scanner::Show { + parent, + id, + poster_file_name, + } => Self::Show { + parent, + id, + poster_file_name, + }, + show::Scanner::Season { + show, + number, + poster_file_name, + } => Self::Season { + show, + number, + poster_file_name, + }, + show::Scanner::Episode { + show, + season, + number, + media_file_name, + poster_file_name, + } => Self::Episode { + show, + season, + number, + media_file_name, + poster_file_name, + }, + } + } +} + +impl Scanner { + /// Detect the type of a folder and call the correct scanner. Use + /// this only for detecting possibly ambiguous media: + /// - Collections + /// - Movies + /// - Shows + pub fn scan_detect_folder( + path: &Path, + parent: Option, + ) -> impl Stream { + enum MediaType { + Collection, + Movie, + Show, + } + + let media_folder_re = MEDIA_FOLDER_REGEX.get_or_init(|| { + Regex::new(r"^[\w ]+ \(\d+\) \[\d+\]$").unwrap_or_else(|_| panic!("regex is invalid")) + }); + let season_folder_re = SEASON_FOLDER_REGEX + .get_or_init(|| Regex::new(r"^S\d+$").unwrap_or_else(|_| panic!("regex is invalid"))); + + stream!({ + let Some(dir_name) = path.file_name().and_then(OsStr::to_str) else { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFolder), + }; + return; + }; + + let Some(Ok(id)) = dir_name + .split_once('[') + .and_then(|(_, s)| s.split_once(']')) + .map(|(s, _)| s.parse::()) + else { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFolder), + }; + return; + }; + + let media_type: MediaType; + if media_folder_re.is_match(dir_name) { + let dirs = match fs::read_dir(path).await { + Ok(dirs) => dirs, + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::ReadDir(err)), + }; + return; + } + }; + + let mut is_show = false; + + for await dir in ReadDirStream::new(dirs) { + match dir { + Ok(dir) => { + let filetype = match dir.file_type().await { + Ok(filetype) => filetype, + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::FileType(err)), + }; + continue; + } + }; + if !filetype.is_dir() { + continue; + } + + let dir_path = dir.path(); + let Some(folder_name) = dir_path.file_name().and_then(OsStr::to_str) + else { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFolder), + }; + continue; + }; + + if season_folder_re.is_match(folder_name) { + is_show = true; + break; + } + } + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::ReadDirEntry(err)), + }; + } + } + } + + if is_show { + media_type = MediaType::Show; + } else { + media_type = MediaType::Movie; + } + } else { + media_type = MediaType::Collection; + } + + match media_type { + MediaType::Collection => { + for await event in collection::Scanner::scan_collection( + path, + parent, + CollectionId::from_raw(id), + ) { + yield event.map(|e| e.into()); + } + } + MediaType::Movie => { + for await event in + movie::Scanner::scan_movie(path, parent, MovieId::from_raw(id)) + { + yield event.map(|e| e.into()); + } + } + MediaType::Show => { + for await event in show::Scanner::scan_show(path, parent, ShowId::from_raw(id)) + { + yield event.map(|e| e.into()); + } + } + } + }) + } +} diff --git a/crates/fs/src/scanner/library.rs b/crates/fs/src/scanner/library.rs new file mode 100644 index 0000000..37fa74d --- /dev/null +++ b/crates/fs/src/scanner/library.rs @@ -0,0 +1,83 @@ +//! The library scanner will scan an entire directory using the generic +//! scanner + +use std::ffi::OsStr; +use std::path::Path; + +use async_stream::stream; +use tokio::fs; +use tokio_stream::Stream; +use tokio_stream::wrappers::ReadDirStream; + +use crate::Error; +use crate::scanner::generic; + +/// A library item +pub type Item = crate::Item; + +/// The scanner for collections +pub enum Scanner {} + +impl Scanner { + /// Scan a folder for a library + pub fn scan_library(path: &Path) -> impl Stream { + stream!({ + let dirs = match fs::read_dir(path).await { + Ok(dirs) => dirs, + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::ReadDir(err)), + }; + return; + } + }; + + let mut subdirs_to_scan = Vec::new(); + + for await dir in ReadDirStream::new(dirs) { + match dir { + Ok(dir) => { + let filetype = match dir.file_type().await { + Ok(filetype) => filetype, + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::FileType(err)), + }; + continue; + } + }; + + let path = dir.path(); + if filetype.is_dir() { + subdirs_to_scan.push(path); + continue; + } + + match path.extension().and_then(OsStr::to_str) { + Some(_) | None => { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFile), + }; + } + } + } + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::ReadDirEntry(err)), + } + } + } + } + + for subdir in subdirs_to_scan { + for await event in generic::Scanner::scan_detect_folder(&subdir, None) { + yield event; + } + } + }) + } +} diff --git a/crates/fs/src/scanner/mod.rs b/crates/fs/src/scanner/mod.rs new file mode 100644 index 0000000..3d9e364 --- /dev/null +++ b/crates/fs/src/scanner/mod.rs @@ -0,0 +1,16 @@ +//! This module contains all of the filesystem scanner modules +//! +//! The most common scanner to use is [generic::Scanner] which will +//! automatically detect and use the appropriate scanner. + +pub mod library; + +pub mod generic; + +pub mod collection; + +pub mod movie; + +pub mod episode; +pub mod season; +pub mod show; diff --git a/crates/fs/src/scanner/movie.rs b/crates/fs/src/scanner/movie.rs new file mode 100644 index 0000000..f513dbf --- /dev/null +++ b/crates/fs/src/scanner/movie.rs @@ -0,0 +1,142 @@ +//! The movie scanner will scan a folder and exit + +use std::ffi::OsStr; +use std::path::Path; + +use flix_model::id::{CollectionId, MovieId}; + +use async_stream::stream; +use tokio::fs; +use tokio_stream::Stream; +use tokio_stream::wrappers::ReadDirStream; + +use crate::Error; +use crate::macros::{is_image_extension, is_media_extension}; + +/// An movie item +pub type Item = crate::Item; + +/// The scanner for movies +pub enum Scanner { + /// A scanned movie + Movie { + /// The ID of the parent collection (if any) + parent: Option, + /// The ID of the movie + id: MovieId, + /// The file name of the media file + media_file_name: String, + /// The file name of the poster file + poster_file_name: Option, + }, +} + +impl Scanner { + /// Scan a folder for a movie + pub fn scan_movie( + path: &Path, + parent: Option, + id: MovieId, + ) -> impl Stream { + stream!({ + let dirs = match fs::read_dir(path).await { + Ok(dirs) => dirs, + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::ReadDir(err)), + }; + return; + } + }; + + let mut media_file_name = None; + let mut poster_file_name = None; + + for await dir in ReadDirStream::new(dirs) { + match dir { + Ok(dir) => { + let filetype = match dir.file_type().await { + Ok(filetype) => filetype, + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::FileType(err)), + }; + continue; + } + }; + if !filetype.is_file() { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedNonFile), + }; + continue; + } + + let path = dir.path(); + match path.extension().and_then(OsStr::to_str) { + is_media_extension!() => { + if media_file_name.is_some() { + yield Item { + path: path.to_owned(), + event: Err(Error::DuplicateMediaFile), + }; + continue; + } + media_file_name = path + .file_name() + .and_then(|s| s.to_str()) + .map(ToOwned::to_owned); + continue; + } + is_image_extension!() => { + if poster_file_name.is_some() { + yield Item { + path: path.to_owned(), + event: Err(Error::DuplicatePosterFile), + }; + continue; + } + poster_file_name = path + .file_name() + .and_then(|s| s.to_str()) + .map(ToOwned::to_owned); + } + Some(_) | None => { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFile), + }; + } + } + } + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::ReadDirEntry(err)), + } + } + } + } + + let Some(media_file_name) = media_file_name else { + yield Item { + path: path.to_owned(), + event: Err(Error::Incomplete), + }; + return; + }; + + yield Item { + path: path.to_owned(), + event: Ok(Self::Movie { + parent, + id, + media_file_name, + poster_file_name, + }), + }; + }) + } +} diff --git a/crates/fs/src/scanner/season.rs b/crates/fs/src/scanner/season.rs new file mode 100644 index 0000000..faf4592 --- /dev/null +++ b/crates/fs/src/scanner/season.rs @@ -0,0 +1,215 @@ +//! The episode scanner will scan a folder and its children + +use std::ffi::OsStr; +use std::path::Path; + +use flix_model::id::ShowId; +use flix_model::numbers::{EpisodeNumber, EpisodeNumbers, SeasonNumber}; + +use async_stream::stream; +use tokio::fs; +use tokio_stream::Stream; +use tokio_stream::wrappers::ReadDirStream; + +use crate::Error; +use crate::macros::is_image_extension; +use crate::scanner::episode; + +/// A season item +pub type Item = crate::Item; + +/// The scanner for seasons +pub enum Scanner { + /// A scanned episode + Season { + /// The ID of the show this episode belongs to + show: ShowId, + /// The season this episode belongs to + number: SeasonNumber, + /// The file name of the poster file + poster_file_name: Option, + }, + /// A scanned episode + Episode { + /// The ID of the show this episode belongs to + show: ShowId, + /// The season this episode belongs to + season: SeasonNumber, + /// The number(s) of this episode + number: EpisodeNumbers, + /// The file name of the media file + media_file_name: String, + /// The file name of the poster file + poster_file_name: Option, + }, +} + +impl From for Scanner { + fn from(value: episode::Scanner) -> Self { + match value { + episode::Scanner::Episode { + show, + season, + number, + media_file_name, + poster_file_name, + } => Self::Episode { + show, + season, + number, + media_file_name, + poster_file_name, + }, + } + } +} + +impl Scanner { + /// Scan a folder for a season and its episodes + pub fn scan_season( + path: &Path, + show: ShowId, + number: SeasonNumber, + ) -> impl Stream { + stream!({ + let dirs = match fs::read_dir(path).await { + Ok(dirs) => dirs, + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::ReadDir(err)), + }; + return; + } + }; + + let mut poster_file_name = None; + let mut episode_dirs_to_scan = Vec::new(); + + for await dir in ReadDirStream::new(dirs) { + match dir { + Ok(dir) => { + let filetype = match dir.file_type().await { + Ok(filetype) => filetype, + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::FileType(err)), + }; + continue; + } + }; + + let path = dir.path(); + if filetype.is_dir() { + episode_dirs_to_scan.push(path); + continue; + } + + match path.extension().and_then(OsStr::to_str) { + is_image_extension!() => { + if poster_file_name.is_some() { + yield Item { + path: path.to_owned(), + event: Err(Error::DuplicatePosterFile), + }; + continue; + } + poster_file_name = path + .file_name() + .and_then(|s| s.to_str()) + .map(ToOwned::to_owned); + } + Some(_) | None => { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFile), + }; + } + } + } + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::ReadDirEntry(err)), + } + } + } + } + + yield Item { + path: path.to_owned(), + event: Ok(Self::Season { + show, + number, + poster_file_name, + }), + }; + + for episode_dir in episode_dirs_to_scan { + let Some(episode_dir_name) = episode_dir.file_name().and_then(OsStr::to_str) else { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFolder), + }; + continue; + }; + + let Some((_, s_e_str)) = episode_dir_name.split_once('S') else { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFolder), + }; + continue; + }; + let Some((s_str, e_str)) = s_e_str.split_once('E') else { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFolder), + }; + continue; + }; + + let Ok(season) = s_str.parse::() else { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFolder), + }; + continue; + }; + if season != number { + yield Item { + path: path.to_owned(), + event: Err(Error::Inconsistent), + }; + continue; + } + + let Ok(episode_numbers) = e_str + .split('E') + .map(|s| s.parse::()) + .collect::, _>>() + else { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFolder), + }; + continue; + }; + let Ok(episode_numbers) = EpisodeNumbers::try_from(episode_numbers.as_ref()) else { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFolder), + }; + continue; + }; + + for await event in + episode::Scanner::scan_episode(&episode_dir, show, number, episode_numbers) + { + yield event.map(|e| e.into()); + } + } + }) + } +} diff --git a/crates/fs/src/scanner/show.rs b/crates/fs/src/scanner/show.rs new file mode 100644 index 0000000..bfeee18 --- /dev/null +++ b/crates/fs/src/scanner/show.rs @@ -0,0 +1,193 @@ +//! The show scanner will scan a folder and its children + +use std::ffi::OsStr; +use std::path::Path; + +use flix_model::id::{CollectionId, ShowId}; +use flix_model::numbers::{EpisodeNumbers, SeasonNumber}; + +use async_stream::stream; +use tokio::fs; +use tokio_stream::Stream; +use tokio_stream::wrappers::ReadDirStream; + +use crate::Error; +use crate::macros::is_image_extension; +use crate::scanner::season; + +/// A show item +pub type Item = crate::Item; + +/// The scanner for shows +pub enum Scanner { + /// A scanned show + Show { + /// The ID of the parent collection (if any) + parent: Option, + /// The ID of the show this episode belongs to + id: ShowId, + /// The file name of the poster file + poster_file_name: Option, + }, + /// A scanned episode + Season { + /// The ID of the show this episode belongs to + show: ShowId, + /// The season this episode belongs to + number: SeasonNumber, + /// The file name of the poster file + poster_file_name: Option, + }, + /// A scanned episode + Episode { + /// The ID of the show this episode belongs to + show: ShowId, + /// The season this episode belongs to + season: SeasonNumber, + /// The number(s) of this episode + number: EpisodeNumbers, + /// The file name of the media file + media_file_name: String, + /// The file name of the poster file + poster_file_name: Option, + }, +} + +impl From for Scanner { + fn from(value: season::Scanner) -> Self { + match value { + season::Scanner::Season { + show, + number, + poster_file_name, + } => Self::Season { + show, + number, + poster_file_name, + }, + season::Scanner::Episode { + show, + season, + number, + media_file_name, + poster_file_name, + } => Self::Episode { + show, + season, + number, + media_file_name, + poster_file_name, + }, + } + } +} + +impl Scanner { + /// Scan a folder for a show and its seasons/episodes + pub fn scan_show( + path: &Path, + parent: Option, + id: ShowId, + ) -> impl Stream { + stream!({ + let dirs = match fs::read_dir(path).await { + Ok(dirs) => dirs, + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::ReadDir(err)), + }; + return; + } + }; + + let mut poster_file_name = None; + let mut season_dirs_to_scan = Vec::new(); + + for await dir in ReadDirStream::new(dirs) { + match dir { + Ok(dir) => { + let filetype = match dir.file_type().await { + Ok(filetype) => filetype, + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::FileType(err)), + }; + continue; + } + }; + + let path = dir.path(); + if filetype.is_dir() { + season_dirs_to_scan.push(path); + continue; + } + + match path.extension().and_then(OsStr::to_str) { + is_image_extension!() => { + if poster_file_name.is_some() { + yield Item { + path: path.to_owned(), + event: Err(Error::DuplicatePosterFile), + }; + continue; + } + poster_file_name = path + .file_name() + .and_then(|s| s.to_str()) + .map(ToOwned::to_owned); + } + Some(_) | None => { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFile), + }; + } + } + } + Err(err) => { + yield Item { + path: path.to_owned(), + event: Err(Error::ReadDirEntry(err)), + } + } + } + } + + yield Item { + path: path.to_owned(), + event: Ok(Self::Show { + parent, + id, + poster_file_name, + }), + }; + + for season_dir in season_dirs_to_scan { + let Some(season_dir_name) = season_dir.file_name().and_then(OsStr::to_str) else { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFolder), + }; + continue; + }; + + let Some(Ok(season_number)) = season_dir_name + .split_once('S') + .map(|(_, s)| s.parse::()) + else { + yield Item { + path: path.to_owned(), + event: Err(Error::UnexpectedFolder), + }; + continue; + }; + + for await event in season::Scanner::scan_season(&season_dir, id, season_number) { + yield event.map(|e| e.into()); + } + } + }) + } +} diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml new file mode 100644 index 0000000..7b55394 --- /dev/null +++ b/crates/model/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "flix-model" +version = "0.0.9" + +categories = [] +description = "Core types for flix data" +repository = "https://github.com/QuantumShade/flix" + +authors.workspace = true +edition.workspace = true +license-file.workspace = true +rust-version.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[features] +default = [] +serde = ["dep:serde"] + +[dependencies] +seamantic = { workspace = true } + +serde = { workspace = true, optional = true, features = ["std", "derive"] } +thiserror = { workspace = true } diff --git a/crates/model/README.md b/crates/model/README.md new file mode 100644 index 0000000..bc2f0a6 --- /dev/null +++ b/crates/model/README.md @@ -0,0 +1,5 @@ +# flix-model + +[![Crates Version](https://img.shields.io/crates/v/flix-model.svg)](https://crates.io/crates/flix-model) + +A library providing core types for flix data diff --git a/crates/model/src/id.rs b/crates/model/src/id.rs new file mode 100644 index 0000000..ccd36da --- /dev/null +++ b/crates/model/src/id.rs @@ -0,0 +1,26 @@ +//! This module contains types relating to flix media IDs + +use seamantic::model::id::Id; + +/// Type alias for the raw ID representation +pub use seamantic::model::id::SeaOrmRepr as RawId; + +#[doc(hidden)] +pub enum Library {} +/// Type alias for a library ID +pub type LibraryId = Id; + +#[doc(hidden)] +pub enum Collection {} +/// Type alias for a collection ID +pub type CollectionId = Id; + +#[doc(hidden)] +pub enum Movie {} +/// Type alias for a movie ID +pub type MovieId = Id; + +#[doc(hidden)] +pub enum Show {} +/// Type alias for a show ID +pub type ShowId = Id; diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs new file mode 100644 index 0000000..4d390e8 --- /dev/null +++ b/crates/model/src/lib.rs @@ -0,0 +1,6 @@ +//! flix-model provides core types for flix data + +#![cfg_attr(docsrs, feature(doc_cfg))] + +pub mod id; +pub mod numbers; diff --git a/crates/model/src/numbers.rs b/crates/model/src/numbers.rs new file mode 100644 index 0000000..f1ce915 --- /dev/null +++ b/crates/model/src/numbers.rs @@ -0,0 +1,78 @@ +//! This module contains season and episode numbers and related errors + +use core::ops::RangeInclusive; +use std::collections::HashSet; + +/// Type alias for representing season numbers +pub type SeasonNumber = u32; +/// Type alias for representing episode numbers +pub type EpisodeNumber = u32; + +/// Potential errors when building EpisodeNumbers +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// There are no episodes + #[error("zero episodes")] + Zero, + /// There are gaps in the episodes + #[error("noncontiguous episodes")] + Noncontiguous, +} + +/// A wrapper for handling single and multi-episode entries +#[derive(Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct EpisodeNumbers(RangeInclusive); + +impl TryFrom<&[EpisodeNumber]> for EpisodeNumbers { + type Error = Error; + + fn try_from(value: &[EpisodeNumber]) -> Result { + match value { + [] => Err(Error::Zero), + [n] => Ok(Self(*n..=*n)), + _ => { + // min and max will always exist + let min = value.iter().copied().min().unwrap_or_default(); + let max = value.iter().copied().max().unwrap_or_default(); + let len = value.len(); + + if usize::try_from(max.saturating_sub(min).saturating_add(1)) != Ok(len) { + return Err(Error::Noncontiguous); + } + + let set: HashSet<_> = value.iter().copied().collect(); + if set.len() != len { + return Err(Error::Noncontiguous); + } + + Ok(Self(min..=max)) + } + } + } +} + +impl EpisodeNumbers { + /// Get the range of episodes + pub fn as_range(&self) -> &RangeInclusive { + &self.0 + } +} + +// impl EpisodeNumbers { +// /// Get the primary episode number of this episode +// pub fn primary_episode_number(&self) -> Option { +// match self { +// EpisodeNumbers::Single { number } => Some(*number), +// EpisodeNumbers::Multiple { numbers } => numbers.first().copied(), +// } +// } + +// /// Get additional episode numbers of this episode +// pub fn additional_episode_numbers(&self) -> &[EpisodeNumber] { +// match self { +// EpisodeNumbers::Single { number: _ } => &[], +// EpisodeNumbers::Multiple { numbers } => numbers.get(1..).unwrap_or(&[]), +// } +// } +// } diff --git a/crates/tmdb/Cargo.toml b/crates/tmdb/Cargo.toml index 4b14222..6eb1a2a 100644 --- a/crates/tmdb/Cargo.toml +++ b/crates/tmdb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "flix-tmdb" -version = "0.0.8" +version = "0.0.9" categories = [] description = "Clients and models for fetching data from TMDB" @@ -11,13 +11,30 @@ edition.workspace = true license-file.workspace = true rust-version.workspace = true +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + [lints] workspace = true +[features] +default = [] +sea-orm = ["dep:sea-orm"] + [dependencies] +flix-model = { workspace = true } + chrono = { workspace = true, features = ["serde"] } +governor = { workspace = true, features = ["std", "jitter"] } +nonzero_ext = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls-tls"] } serde = { workspace = true, features = ["derive"] } thiserror = { workspace = true } url = { workspace = true } url-macro = { workspace = true } + +sea-orm = { workspace = true, optional = true } + +[dev-dependencies] +serde_test = { workspace = true } diff --git a/crates/tmdb/src/api/collections.rs b/crates/tmdb/src/api/collections.rs index f632d3b..316b07e 100644 --- a/crates/tmdb/src/api/collections.rs +++ b/crates/tmdb/src/api/collections.rs @@ -1,7 +1,13 @@ +//! Collections API + +use core::time::Duration; use std::rc::Rc; +use governor::Jitter; + use crate::Config; -use crate::model::{Collection, CollectionId}; +use crate::model::Collection; +use crate::model::id::CollectionId; use super::{Error, make_request}; @@ -24,12 +30,20 @@ impl Client { id: impl Into, language: Option<&str>, ) -> Result { + self.config + .limiter + .until_ready_with_jitter(Jitter::new( + Duration::from_millis(0), + Duration::from_millis(50), + )) + .await; + Ok(self .config .client .execute(make_request( &self.config, - &format!("/3/collection/{}", id.into()), + &format!("/3/collection/{}", id.into().into_raw()), language, )?) .await? diff --git a/crates/tmdb/src/api/episodes.rs b/crates/tmdb/src/api/episodes.rs index 1738808..9a87665 100644 --- a/crates/tmdb/src/api/episodes.rs +++ b/crates/tmdb/src/api/episodes.rs @@ -1,7 +1,15 @@ +//! Episodes API + +use core::time::Duration; use std::rc::Rc; +use flix_model::numbers::{EpisodeNumber, SeasonNumber}; + +use governor::Jitter; + use crate::Config; -use crate::model::{Episode, ShowId}; +use crate::model::Episode; +use crate::model::id::ShowId; use super::{Error, make_request}; @@ -22,10 +30,18 @@ impl Client { pub async fn get_details( &self, id: impl Into, - season: impl Into, - episode: impl Into, + season: impl Into, + episode: impl Into, language: Option<&str>, ) -> Result { + self.config + .limiter + .until_ready_with_jitter(Jitter::new( + Duration::from_millis(0), + Duration::from_millis(50), + )) + .await; + Ok(self .config .client @@ -33,7 +49,7 @@ impl Client { &self.config, &format!( "/3/tv/{}/season/{}/episode/{}", - id.into(), + id.into().into_raw(), season.into(), episode.into() ), diff --git a/crates/tmdb/src/api/genres.rs b/crates/tmdb/src/api/genres.rs deleted file mode 100644 index c5cc653..0000000 --- a/crates/tmdb/src/api/genres.rs +++ /dev/null @@ -1,58 +0,0 @@ -use std::rc::Rc; - -use crate::Config; -use crate::model::{MovieGenre, ShowGenre}; - -use super::{Error, make_request}; - -/// TMDB Genre API client -pub struct Client { - config: Rc, -} - -impl Client { - /// Create a new client with the given configuration - pub fn new(config: Rc) -> Self { - Self { config } - } -} - -impl Client { - /// Fetch the list of all valid movie genres - pub async fn get_movie_genres(&self, language: Option<&str>) -> Result, Error> { - #[derive(Debug, serde::Deserialize)] - struct Genres { - genres: Vec, - } - - let genres: Genres = self - .config - .client - .execute(make_request(&self.config, "/3/genre/movie/list", language)?) - .await? - .error_for_status()? - .json() - .await?; - - Ok(genres.genres) - } - - /// Fetch the list of all valid show genres - pub async fn get_tv_genres(&self, language: Option<&str>) -> Result, Error> { - #[derive(Debug, serde::Deserialize)] - struct Genres { - genres: Vec, - } - - let genres: Genres = self - .config - .client - .execute(make_request(&self.config, "/3/genre/tv/list", language)?) - .await? - .error_for_status()? - .json() - .await?; - - Ok(genres.genres) - } -} diff --git a/crates/tmdb/src/api/mod.rs b/crates/tmdb/src/api/mod.rs index 4b7fcd0..2a4d0e2 100644 --- a/crates/tmdb/src/api/mod.rs +++ b/crates/tmdb/src/api/mod.rs @@ -1,19 +1,14 @@ +//! TMDB API clients + use reqwest::Request; use reqwest::header; use crate::Config; -/// Collections API pub mod collections; -/// Episodes API pub mod episodes; -/// Genres API -pub mod genres; -/// Movies API pub mod movies; -/// Seasons API pub mod seasons; -/// Shows API pub mod shows; /// A generic error wrapping Url and Reqwest errors diff --git a/crates/tmdb/src/api/movies.rs b/crates/tmdb/src/api/movies.rs index 21f2f5c..6eaaa9b 100644 --- a/crates/tmdb/src/api/movies.rs +++ b/crates/tmdb/src/api/movies.rs @@ -1,7 +1,13 @@ +//! Movies API + +use core::time::Duration; use std::rc::Rc; +use governor::Jitter; + use crate::Config; -use crate::model::{Movie, MovieId}; +use crate::model::Movie; +use crate::model::id::MovieId; use super::{Error, make_request}; @@ -24,12 +30,20 @@ impl Client { id: impl Into, language: Option<&str>, ) -> Result { + self.config + .limiter + .until_ready_with_jitter(Jitter::new( + Duration::from_millis(0), + Duration::from_millis(50), + )) + .await; + Ok(self .config .client .execute(make_request( &self.config, - &format!("/3/movie/{}", id.into()), + &format!("/3/movie/{}", id.into().into_raw()), language, )?) .await? diff --git a/crates/tmdb/src/api/seasons.rs b/crates/tmdb/src/api/seasons.rs index 8f5f058..60b71ce 100644 --- a/crates/tmdb/src/api/seasons.rs +++ b/crates/tmdb/src/api/seasons.rs @@ -1,7 +1,15 @@ +//! Seasons API + +use core::time::Duration; use std::rc::Rc; +use flix_model::numbers::SeasonNumber; + +use governor::Jitter; + use crate::Config; -use crate::model::{Season, ShowId}; +use crate::model::Season; +use crate::model::id::ShowId; use super::{Error, make_request}; @@ -22,15 +30,23 @@ impl Client { pub async fn get_details( &self, id: impl Into, - season: impl Into, + season: impl Into, language: Option<&str>, ) -> Result { + self.config + .limiter + .until_ready_with_jitter(Jitter::new( + Duration::from_millis(0), + Duration::from_millis(50), + )) + .await; + Ok(self .config .client .execute(make_request( &self.config, - &format!("/3/tv/{}/season/{}", id.into(), season.into()), + &format!("/3/tv/{}/season/{}", id.into().into_raw(), season.into()), language, )?) .await? diff --git a/crates/tmdb/src/api/shows.rs b/crates/tmdb/src/api/shows.rs index 8bf9301..437dc53 100644 --- a/crates/tmdb/src/api/shows.rs +++ b/crates/tmdb/src/api/shows.rs @@ -1,7 +1,13 @@ +//! Shows API + +use core::time::Duration; use std::rc::Rc; +use governor::Jitter; + use crate::Config; -use crate::model::{Show, ShowId}; +use crate::model::Show; +use crate::model::id::ShowId; use super::{Error, make_request}; @@ -24,12 +30,20 @@ impl Client { id: impl Into, language: Option<&str>, ) -> Result { + self.config + .limiter + .until_ready_with_jitter(Jitter::new( + Duration::from_millis(0), + Duration::from_millis(50), + )) + .await; + Ok(self .config .client .execute(make_request( &self.config, - &format!("/3/tv/{}", id.into()), + &format!("/3/tv/{}", id.into().into_raw()), language, )?) .await? diff --git a/crates/tmdb/src/client.rs b/crates/tmdb/src/client.rs index dccdaa8..67b2c2e 100644 --- a/crates/tmdb/src/client.rs +++ b/crates/tmdb/src/client.rs @@ -4,7 +4,6 @@ use crate::{Config, api}; /// The primary client that references all other clients pub struct Client { - genres: api::genres::Client, collections: api::collections::Client, movies: api::movies::Client, shows: api::shows::Client, @@ -22,7 +21,6 @@ impl Client { pub fn new_with_config(config: Config) -> Self { let config = Rc::new(config); Self { - genres: api::genres::Client::new(config.clone()), collections: api::collections::Client::new(config.clone()), movies: api::movies::Client::new(config.clone()), shows: api::shows::Client::new(config.clone()), @@ -33,11 +31,6 @@ impl Client { } impl Client { - /// Access the Genres API - pub fn genres(&self) -> &api::genres::Client { - &self.genres - } - /// Access the Collections API pub fn collections(&self) -> &api::collections::Client { &self.collections diff --git a/crates/tmdb/src/config.rs b/crates/tmdb/src/config.rs index 09eaa6d..048149e 100644 --- a/crates/tmdb/src/config.rs +++ b/crates/tmdb/src/config.rs @@ -1,3 +1,7 @@ +use governor::clock::MonotonicClock; +use governor::state::{InMemoryState, NotKeyed}; +use governor::{Quota, RateLimiter}; +use nonzero_ext::nonzero; use url::Url; use url_macro::url; @@ -7,6 +11,8 @@ pub struct Config { pub base_url: Url, /// The reqwest client that is used for every request pub client: reqwest::Client, + /// The rate limiter to use for the client + pub limiter: RateLimiter, /// The bearer token for readonly access to the API pub bearer_token: String, /// An optional user agent string to provide to the API @@ -19,6 +25,7 @@ impl Config { Self { base_url: url!("https://api.themoviedb.org"), client: reqwest::Client::new(), + limiter: RateLimiter::direct(Quota::per_second(nonzero!(30u32))), bearer_token, user_agent: None, } diff --git a/crates/tmdb/src/lib.rs b/crates/tmdb/src/lib.rs index bb1c515..4ede534 100644 --- a/crates/tmdb/src/lib.rs +++ b/crates/tmdb/src/lib.rs @@ -1,8 +1,8 @@ //! flix-tmdb provides clients and models for fetching data from TMDB -/// TMDB API clients +#![cfg_attr(docsrs, feature(doc_cfg))] + pub mod api; -/// Deserializable types from the TMDB API pub mod model; mod client; diff --git a/crates/tmdb/src/model/collection.rs b/crates/tmdb/src/model/collection.rs index ee37114..e55abe2 100644 --- a/crates/tmdb/src/model/collection.rs +++ b/crates/tmdb/src/model/collection.rs @@ -1,4 +1,4 @@ -use super::{CollectionId, MovieId}; +use super::id::{CollectionId, MovieId}; /// A deserialized Collection from the TMDB API #[derive(Debug, Clone, serde::Deserialize)] @@ -20,4 +20,6 @@ pub struct Collection { pub struct Item { /// The movie's TMDB ID pub id: MovieId, + /// The movie's title + pub title: String, } diff --git a/crates/tmdb/src/model/episode.rs b/crates/tmdb/src/model/episode.rs index e14807e..866c7e5 100644 --- a/crates/tmdb/src/model/episode.rs +++ b/crates/tmdb/src/model/episode.rs @@ -1,10 +1,16 @@ +use core::time::Duration; + +use flix_model::numbers::EpisodeNumber; + use chrono::NaiveDate; +use super::duration_from_minutes; + /// A deserialized Episode from the TMDB API #[derive(Debug, Clone, serde::Deserialize)] pub struct Episode { /// The episode's number - pub episode_number: u32, + pub episode_number: EpisodeNumber, /// The episode's title #[serde(rename = "name")] pub title: String, @@ -12,4 +18,7 @@ pub struct Episode { pub overview: String, /// The episode's air date pub air_date: NaiveDate, + /// The movie's runtime + #[serde(deserialize_with = "duration_from_minutes")] + pub runtime: Duration, } diff --git a/crates/tmdb/src/model/genre.rs b/crates/tmdb/src/model/genre.rs deleted file mode 100644 index 298d86c..0000000 --- a/crates/tmdb/src/model/genre.rs +++ /dev/null @@ -1,19 +0,0 @@ -use super::id::{MovieGenreId, ShowGenreId}; - -/// A deserialized movie Genre from the TMDB API -#[derive(Debug, Clone, serde::Deserialize)] -pub struct MovieGenre { - /// The genre's TMDB ID - pub id: MovieGenreId, - /// The genre's name - pub name: String, -} - -/// A deserialized show Genre from the TMDB API -#[derive(Debug, Clone, serde::Deserialize)] -pub struct ShowGenre { - /// The genre's TMDB ID - pub id: ShowGenreId, - /// The genre's name - pub name: String, -} diff --git a/crates/tmdb/src/model/id.rs b/crates/tmdb/src/model/id.rs index 961d176..5b7464e 100644 --- a/crates/tmdb/src/model/id.rs +++ b/crates/tmdb/src/model/id.rs @@ -1,90 +1,242 @@ +//! Typed TMDB IDs + +use core::cmp::Ordering; use core::fmt; use core::hash::{Hash, Hasher}; use core::marker::PhantomData; -/// The TMDB ID type of a movie genre -pub type MovieGenreId = TmdbId; -/// The TMDB ID type of a show genre -pub type ShowGenreId = TmdbId; -/// The TMDB ID type of a collection -pub type CollectionId = TmdbId; -/// The TMDB ID type of a movie -pub type MovieId = TmdbId; -/// The TMDB ID type of a show -pub type ShowId = TmdbId; +#[cfg(feature = "sea-orm")] +use sea_orm::sea_query::{ArrayType, Nullable, ValueType, ValueTypeErr}; +#[cfg(feature = "sea-orm")] +use sea_orm::{ColIdx, ColumnType, DbErr, QueryResult, TryFromU64, TryGetError, TryGetable, Value}; -pub enum MovieGenre {} -pub enum ShowGenre {} -pub enum Collection {} -pub enum Movie {} -pub enum Show {} +/// The internal representation used by TMDB +pub type TmdbRepr = u32; -/// The inner type of TmdbId -pub type Inner = u32; - -/// Wraps an ID from TMDB, the generic parameter is to enforce that -/// IDs for different types of media are not interchangeable +/// An opaque type representing a TMDB ID #[derive(serde::Serialize, serde::Deserialize)] #[serde(transparent)] #[repr(transparent)] -pub struct TmdbId { - inner: Inner, +pub struct Id { + id: TmdbRepr, #[serde(skip_serializing, default)] _phantom: PhantomData, } -impl TmdbId { - /// Extract the inner value - pub fn inner(self) -> Inner { - self.inner - } -} - -impl From for TmdbId { - fn from(value: Inner) -> Self { - Self { - inner: value, - _phantom: PhantomData, - } - } -} - -impl From> for Inner { - fn from(value: TmdbId) -> Self { - value.inner - } -} - -impl fmt::Debug for TmdbId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.inner.fmt(f) - } -} - -impl fmt::Display for TmdbId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.inner.fmt(f) - } -} - -impl Clone for TmdbId { +// Manual implementation since `T: Clone` is not required +impl Clone for Id { fn clone(&self) -> Self { *self } } -impl Copy for TmdbId {} +// Manual implementation since `T: Copy` is not required +impl Copy for Id {} -impl PartialEq for TmdbId { +// Manual implementation since `T: PartialEq` is not required +impl PartialEq for Id { fn eq(&self, other: &Self) -> bool { - self.inner == other.inner + self.id == other.id } } -impl Eq for TmdbId {} +// Manual implementation since `T: Eq` is not required +impl Eq for Id {} -impl Hash for TmdbId { +// Manual implementation since `T: PartialOrd` is not required +impl PartialOrd for Id { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +// Manual implementation since `T: Ord` is not required +impl Ord for Id { + fn cmp(&self, other: &Self) -> Ordering { + self.id.cmp(&other.id) + } +} + +// Manual implementation since `T: Hash` is not required +impl Hash for Id { fn hash(&self, state: &mut H) { - self.inner.hash(state); + self.id.hash(state); + } +} + +impl Id { + /// Allows the conversion from a raw value to [Id], though the use is discouraged. + pub fn from_raw(raw: TmdbRepr) -> Self { + Self { + id: raw, + _phantom: PhantomData, + } + } + + /// Allows extracting the raw value, though the use is discouraged. + pub fn into_raw(self) -> TmdbRepr { + self.id + } +} + +impl fmt::Debug for Id { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Id") + .field("T", &core::any::type_name::()) + .field("id", &self.id) + .finish() + } +} + +#[cfg(feature = "sea-orm")] +impl ValueType for Id { + fn try_from(v: Value) -> Result { + ::try_from(v).map(|id| Self { + id, + _phantom: PhantomData, + }) + } + + fn type_name() -> String { + format!("Id<{}>", &core::any::type_name::()) + } + + fn array_type() -> ArrayType { + TmdbRepr::array_type() + } + + fn column_type() -> ColumnType { + TmdbRepr::column_type() + } +} + +#[cfg(feature = "sea-orm")] +impl From> for Value { + fn from(value: Id) -> Self { + value.id.into() + } +} + +#[cfg(feature = "sea-orm")] +impl TryGetable for Id { + fn try_get_by(res: &QueryResult, index: I) -> Result { + TmdbRepr::try_get_by(res, index).map(|id| Self { + id, + _phantom: PhantomData, + }) + } +} + +#[cfg(feature = "sea-orm")] +impl TryFromU64 for Id { + fn try_from_u64(n: u64) -> Result { + TmdbRepr::try_from_u64(n).map(|id| Self { + id, + _phantom: PhantomData, + }) + } +} + +#[cfg(feature = "sea-orm")] +impl Nullable for Id { + fn null() -> Value { + TmdbRepr::null() + } +} + +#[cfg(test)] +mod tests { + #[test] + #[cfg(feature = "sea-orm")] + fn test_sea_orm() { + use sea_orm::{ + ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EnumIter, + PrimaryKeyTrait, + }; + + use super::Id; + + #[allow(dead_code)] + #[derive(Debug, Clone, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "ids")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + id: Id, + nullable: Option>, + } + + impl ActiveModelBehavior for ActiveModel {} + + #[allow(dead_code)] + #[derive(Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + } + + #[test] + fn test_serde() { + use super::Id; + + let id: Id<()> = Id::from_raw(1234); + serde_test::assert_tokens(&id, &[serde_test::Token::U32(1234)]); + } +} + +/// Type alias for the raw ID representation +pub use self::TmdbRepr as RawId; + +/// A placeholder type used for CollectionId +pub enum Collection {} +/// Type alias for a collection ID +pub type CollectionId = Id; + +impl From for flix_model::id::CollectionId { + fn from(value: CollectionId) -> Self { + Self::from_raw(value.into_raw().into()) + } +} + +impl TryFrom for CollectionId { + type Error = >::Error; + + fn try_from(value: flix_model::id::CollectionId) -> Result { + value.into_raw().try_into().map(Self::from_raw) + } +} + +/// A placeholder type used for MovieId +pub enum Movie {} +/// Type alias for a movie ID +pub type MovieId = Id; + +impl From for flix_model::id::MovieId { + fn from(value: MovieId) -> Self { + Self::from_raw(value.into_raw().into()) + } +} + +impl TryFrom for MovieId { + type Error = >::Error; + + fn try_from(value: flix_model::id::MovieId) -> Result { + value.into_raw().try_into().map(Self::from_raw) + } +} + +/// A placeholder type used for ShowId +pub enum Show {} +/// Type alias for a show ID +pub type ShowId = Id; + +impl From for flix_model::id::ShowId { + fn from(value: ShowId) -> Self { + Self::from_raw(value.into_raw().into()) + } +} + +impl TryFrom for ShowId { + type Error = >::Error; + + fn try_from(value: flix_model::id::ShowId) -> Result { + value.into_raw().try_into().map(Self::from_raw) } } diff --git a/crates/tmdb/src/model/mod.rs b/crates/tmdb/src/model/mod.rs index 0391a21..45e50db 100644 --- a/crates/tmdb/src/model/mod.rs +++ b/crates/tmdb/src/model/mod.rs @@ -1,18 +1,27 @@ +//! Deserializable types from the TMDB API + +use core::time::Duration; + +use serde::{Deserialize, Deserializer}; + +pub mod id; + mod collection; mod episode; -mod genre; -mod id; mod movie; mod season; mod show; pub use collection::*; pub use episode::*; -pub use genre::*; pub use movie::*; pub use season::*; -pub use serde::*; pub use show::*; -pub use id::{CollectionId, MovieGenreId, MovieId, ShowGenreId, ShowId}; -pub use id::{Inner as TmdbIdInner, TmdbId}; +fn duration_from_minutes<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let minutes = u64::deserialize(deserializer)?; + Ok(Duration::from_secs(minutes.saturating_mul(60))) +} diff --git a/crates/tmdb/src/model/movie.rs b/crates/tmdb/src/model/movie.rs index 0a96e26..dd476f1 100644 --- a/crates/tmdb/src/model/movie.rs +++ b/crates/tmdb/src/model/movie.rs @@ -1,6 +1,9 @@ +use core::time::Duration; + use chrono::NaiveDate; -use super::{CollectionId, MovieGenre, MovieId}; +use super::duration_from_minutes; +use super::id::{CollectionId, MovieId}; /// A deserialized Movie from the TMDB API #[derive(Debug, Clone, serde::Deserialize)] @@ -12,14 +15,15 @@ pub struct Movie { pub collection: Option, /// The movie's title pub title: String, + /// The movie's tagline + pub tagline: String, /// The movie's overview pub overview: String, - /// The list of genres the movie belongs to - pub genres: Vec, /// The movie's release date pub release_date: NaiveDate, - /// The movie's status - pub status: MovieStatus, + /// The movie's runtime + #[serde(deserialize_with = "duration_from_minutes")] + pub runtime: Duration, } /// A deserialized movie's collection from the TMDB API @@ -27,27 +31,15 @@ pub struct Movie { pub struct InCollection { /// The collection's TMDB ID pub id: CollectionId, + /// The collection's title + #[serde(rename = "name")] + pub title: String, } -/// A deserialized movie status from the TMDB API -#[derive(Debug, Clone, Copy, serde::Deserialize)] -pub enum MovieStatus { - /// The movie was cancelled - #[serde(rename = "Canceled")] - Canceled, - /// The movie is in production - #[serde(rename = "In Production")] - InProduction, - /// The movie is planned - #[serde(rename = "Planned")] - Planned, - /// The movie is in post production - #[serde(rename = "Post Production")] - PostProduction, - /// The movie is released - #[serde(rename = "Released")] - Released, - /// The movie is rumored - #[serde(rename = "Rumored")] - Rumored, -} +// TODO: Genres +// pub genres: Vec, +// where: struct Genre { id, name } + +// TODO: Company +// pub companies: Vec +// where: struct Company { id, name } diff --git a/crates/tmdb/src/model/season.rs b/crates/tmdb/src/model/season.rs index c48a209..c413a02 100644 --- a/crates/tmdb/src/model/season.rs +++ b/crates/tmdb/src/model/season.rs @@ -1,12 +1,12 @@ use chrono::NaiveDate; -use super::Episode; +use flix_model::numbers::SeasonNumber; /// A deserialized Season from the TMDB API #[derive(Debug, Clone, serde::Deserialize)] pub struct Season { /// The season's number - pub season_number: u32, + pub season_number: SeasonNumber, /// The season's title #[serde(rename = "name")] pub title: String, @@ -14,6 +14,10 @@ pub struct Season { pub overview: String, /// The season's air date pub air_date: NaiveDate, - /// The list of episodes in this season - pub episodes: Vec, + /// The number of episodes in this season + pub episodes: Vec, } + +/// A placeholder struct for parsing the episodes list for a season +#[derive(Debug, Clone, serde::Deserialize)] +pub struct FakeEpisode {} diff --git a/crates/tmdb/src/model/show.rs b/crates/tmdb/src/model/show.rs index ddb712e..2f198f5 100644 --- a/crates/tmdb/src/model/show.rs +++ b/crates/tmdb/src/model/show.rs @@ -1,6 +1,6 @@ use chrono::NaiveDate; -use super::{ShowGenre, ShowId}; +use super::id::ShowId; /// A deserialized Show from the TMDB API #[derive(Debug, Clone, serde::Deserialize)] @@ -10,39 +10,28 @@ pub struct Show { /// The show's title #[serde(rename = "name")] pub title: String, + /// The show's tagline + pub tagline: String, /// The show's overview pub overview: String, - /// The list of genres this show belongs to - pub genres: Vec, /// The show's first air date pub first_air_date: NaiveDate, /// The show's last air date pub last_air_date: NaiveDate, + /// The total number of episodes in this show + pub number_of_episodes: u32, /// The number of seasons in this show pub number_of_seasons: u32, - /// The show's status - pub status: ShowStatus, } -/// A deserialized show Status from the TMDB API -#[derive(Debug, Clone, Copy, serde::Deserialize)] -pub enum ShowStatus { - /// The show is returning - #[serde(rename = "Returning Series")] - Returning, - /// The show is planned - #[serde(rename = "Planned")] - Planned, - /// The show is in procuction - #[serde(rename = "In Production")] - InProduction, - /// The show has ended - #[serde(rename = "Ended")] - Ended, - /// The show is canceled - #[serde(rename = "Canceled")] - Canceled, - /// The show only released a pilot - #[serde(rename = "Pilot")] - Pilot, -} +// TODO: Genres +// pub genres: Vec, +// where: struct Genre { id, name } + +// TODO: Network +// pub networks: Vec +// where: struct Network { id, name } + +// TODO: Company +// pub companies: Vec +// where: struct Company { id, name }