From 9b18d3ff6d7893f5344da319239c5421272a0599 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 05:14:17 +0200 Subject: [PATCH] remove OIDC/Postgres, replace ApiError w/ AppError, move params to api-types --- Cargo.lock | 694 +----------------- Cargo.toml | 3 +- crates/adapters/auth/Cargo.toml | 6 - crates/adapters/auth/src/lib.rs | 6 - crates/adapters/auth/src/oidc.rs | 245 ------- crates/adapters/postgres/Cargo.toml | 16 - crates/adapters/postgres/src/activity.rs | 83 --- crates/adapters/postgres/src/channel.rs | 329 --------- crates/adapters/postgres/src/lib.rs | 11 - crates/adapters/postgres/src/library.rs | 530 ------------- .../adapters/postgres/src/provider_config.rs | 90 --- crates/adapters/postgres/src/schedule.rs | 349 --------- crates/adapters/postgres/src/settings.rs | 48 -- crates/adapters/postgres/src/transcode.rs | 38 - crates/adapters/postgres/src/user.rs | 140 ---- crates/adapters/postgres/src/wire.rs | 70 -- crates/api-types/src/admin.rs | 5 + crates/api-types/src/iptv.rs | 7 + crates/api-types/src/lib.rs | 9 +- crates/api-types/src/library.rs | 41 ++ crates/domain/Cargo.toml | 1 - crates/domain/src/errors/mod.rs | 10 +- crates/domain/src/value_objects/mod.rs | 2 - crates/domain/src/value_objects/oidc.rs | 345 --------- crates/domain/src/value_objects/tests/oidc.rs | 51 -- crates/infra-wiring/Cargo.toml | 1 - crates/infra-wiring/src/lib.rs | 28 - crates/mcp/Cargo.toml | 2 - crates/mcp/src/main.rs | 12 - crates/presentation/Cargo.toml | 3 - crates/presentation/src/errors.rs | 155 +--- crates/presentation/src/extractors.rs | 40 +- crates/presentation/src/factory.rs | 19 - crates/presentation/src/handlers/admin.rs | 16 +- crates/presentation/src/handlers/auth.rs | 35 +- crates/presentation/src/handlers/channels.rs | 35 +- crates/presentation/src/handlers/config.rs | 4 +- crates/presentation/src/handlers/files.rs | 16 +- crates/presentation/src/handlers/iptv.rs | 13 +- crates/presentation/src/handlers/library.rs | 73 +- crates/presentation/src/handlers/providers.rs | 15 +- crates/presentation/src/handlers/schedule.rs | 14 +- 42 files changed, 243 insertions(+), 3367 deletions(-) delete mode 100644 crates/adapters/auth/src/oidc.rs delete mode 100644 crates/adapters/postgres/Cargo.toml delete mode 100644 crates/adapters/postgres/src/activity.rs delete mode 100644 crates/adapters/postgres/src/channel.rs delete mode 100644 crates/adapters/postgres/src/lib.rs delete mode 100644 crates/adapters/postgres/src/library.rs delete mode 100644 crates/adapters/postgres/src/provider_config.rs delete mode 100644 crates/adapters/postgres/src/schedule.rs delete mode 100644 crates/adapters/postgres/src/settings.rs delete mode 100644 crates/adapters/postgres/src/transcode.rs delete mode 100644 crates/adapters/postgres/src/user.rs delete mode 100644 crates/adapters/postgres/src/wire.rs create mode 100644 crates/api-types/src/iptv.rs delete mode 100644 crates/domain/src/value_objects/oidc.rs delete mode 100644 crates/domain/src/value_objects/tests/oidc.rs diff --git a/Cargo.lock b/Cargo.lock index 6082678..31e0053 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,14 +8,11 @@ version = "0.1.0" dependencies = [ "domain", "jsonwebtoken", - "openidconnect", "password-auth", - "reqwest", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tracing", - "url", ] [[package]] @@ -71,22 +68,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "adapter-postgres" -version = "0.1.0" -dependencies = [ - "adapter-common", - "async-trait", - "chrono", - "domain", - "infra-wiring", - "serde", - "serde_json", - "sqlx", - "tracing", - "uuid", -] - [[package]] name = "adapter-sqlite" version = "0.1.0" @@ -164,7 +145,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", + "cpufeatures", "password-hash", ] @@ -275,12 +256,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - [[package]] name = "base64" version = "0.21.7" @@ -326,15 +301,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -369,23 +335,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - [[package]] name = "chrono" version = "0.4.45" @@ -461,15 +410,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - [[package]] name = "crc" version = "3.4.0" @@ -500,18 +440,6 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -522,51 +450,14 @@ dependencies = [ "typenum", ] -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "darling" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core", + "darling_macro", ] [[package]] @@ -583,37 +474,13 @@ dependencies = [ "syn", ] -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core 0.20.11", - "quote", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", "syn", ] @@ -634,9 +501,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "serde_core", -] [[package]] name = "derive_builder" @@ -653,7 +517,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling 0.20.11", + "darling", "proc-macro2", "quote", "syn", @@ -700,12 +564,11 @@ dependencies = [ "chrono", "chrono-tz", "email_address", - "rand 0.8.7", + "rand", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tokio", - "url", "uuid", ] @@ -721,44 +584,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2", - "subtle", - "zeroize", -] - [[package]] name = "either" version = "1.16.0" @@ -768,27 +593,6 @@ dependencies = [ "serde", ] -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest", - "ff", - "generic-array", - "group", - "hkdf", - "pem-rfc7468", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "email_address" version = "0.2.9" @@ -851,22 +655,6 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1027,7 +815,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] @@ -1050,22 +837,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", - "rand_core 0.10.1", - "wasm-bindgen", -] - -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", ] [[package]] @@ -1080,7 +853,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.14.0", + "indexmap", "slab", "tokio", "tokio-util", @@ -1100,15 +873,9 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", ] -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - [[package]] name = "hashbrown" version = "0.15.5" @@ -1278,7 +1045,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -1455,17 +1221,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -1484,7 +1239,7 @@ version = "0.1.0" dependencies = [ "serde", "sqlx", - "thiserror 2.0.18", + "thiserror", "tracing", "url", ] @@ -1495,15 +1250,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -1607,12 +1353,6 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "matchers" version = "0.2.0" @@ -1635,7 +1375,6 @@ dependencies = [ "adapter-auth", "adapter-event-publisher", "adapter-jellyfin", - "adapter-postgres", "adapter-sqlite", "anyhow", "application", @@ -1645,10 +1384,10 @@ dependencies = [ "dotenvy", "infra-wiring", "rmcp", - "schemars 0.8.22", + "schemars", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "tracing-subscriber", @@ -1735,7 +1474,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.7", + "rand", "smallvec", "zeroize", ] @@ -1790,63 +1529,12 @@ dependencies = [ "libm", ] -[[package]] -name = "oauth2" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" -dependencies = [ - "base64 0.22.1", - "chrono", - "getrandom 0.2.17", - "http", - "rand 0.8.7", - "reqwest", - "serde", - "serde_json", - "serde_path_to_error", - "sha2", - "thiserror 1.0.69", - "url", -] - [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "openidconnect" -version = "4.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" -dependencies = [ - "base64 0.21.7", - "chrono", - "dyn-clone", - "ed25519-dalek", - "hmac", - "http", - "itertools", - "log", - "oauth2", - "p256", - "p384", - "rand 0.8.7", - "rsa", - "serde", - "serde-value", - "serde_json", - "serde_path_to_error", - "serde_plain", - "serde_with", - "sha2", - "subtle", - "thiserror 1.0.69", - "url", -] - [[package]] name = "openssl" version = "0.10.81" @@ -1890,39 +1578,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "ordered-float" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - [[package]] name = "parking" version = "2.2.1" @@ -1961,7 +1616,7 @@ dependencies = [ "argon2", "getrandom 0.2.17", "password-hash", - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -1971,7 +1626,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core 0.6.4", + "rand_core", "subtle", ] @@ -2137,7 +1792,6 @@ dependencies = [ "adapter-event-publisher", "adapter-jellyfin", "adapter-local-files", - "adapter-postgres", "adapter-sqlite", "anyhow", "api-types", @@ -2153,7 +1807,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-util", "tower", @@ -2163,15 +1817,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -2181,62 +1826,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" -dependencies = [ - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.2", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - [[package]] name = "quote" version = "1.0.46" @@ -2260,18 +1849,7 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -2281,7 +1859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -2293,21 +1871,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -2326,26 +1889,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "regex-automata" version = "0.4.15" @@ -2387,8 +1930,6 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", - "quinn", - "rustls", "rustls-pki-types", "serde", "serde_json", @@ -2396,7 +1937,6 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-rustls", "tower", "tower-http", "tower-service", @@ -2404,17 +1944,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", -] - -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", ] [[package]] @@ -2443,10 +1972,10 @@ dependencies = [ "paste", "pin-project-lite", "rmcp-macros", - "schemars 0.8.22", + "schemars", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-util", "tracing", @@ -2476,28 +2005,13 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core 0.6.4", + "rand_core", "signature", "spki", "subtle", "zeroize", ] -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustix" version = "1.1.4" @@ -2518,7 +2032,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2531,7 +2044,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ - "web-time", "zeroize", ] @@ -2588,30 +2100,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "schemars_derive" version = "0.8.22" @@ -2630,20 +2118,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -2667,12 +2141,6 @@ dependencies = [ "libc", ] -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "serde" version = "1.0.228" @@ -2683,16 +2151,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-value" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" -dependencies = [ - "ordered-float", - "serde", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -2730,7 +2188,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ - "indexmap 2.14.0", + "indexmap", "itoa", "memchr", "serde", @@ -2749,15 +2207,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_plain" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" -dependencies = [ - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2770,38 +2219,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_with" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" -dependencies = [ - "base64 0.22.1", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" -dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "sha1" version = "0.10.7" @@ -2809,7 +2226,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest", ] @@ -2820,7 +2237,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest", ] @@ -2856,7 +2273,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -2867,7 +2284,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.18", + "thiserror", "time", ] @@ -2953,7 +2370,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap 2.14.0", + "indexmap", "log", "memchr", "once_cell", @@ -2962,7 +2379,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", "tracing", @@ -3038,7 +2455,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.7", + "rand", "rsa", "serde", "sha1", @@ -3046,7 +2463,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror", "tracing", "uuid", "whoami", @@ -3078,14 +2495,14 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand 0.8.7", + "rand", "serde", "serde_json", "sha2", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror", "tracing", "uuid", "whoami", @@ -3111,7 +2528,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.18", + "thiserror", "tracing", "url", "uuid", @@ -3211,33 +2628,13 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -3585,7 +2982,7 @@ version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" dependencies = [ - "indexmap 2.14.0", + "indexmap", "serde", "serde_json", "utoipa-gen", @@ -3729,25 +3126,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "whoami" version = "1.6.1" diff --git a/Cargo.toml b/Cargo.toml index ad277f0..fbbcdfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/domain", "crates/application", "crates/api-types", "crates/infra-wiring", "crates/adapters/adapter-common", "crates/adapters/sqlite", "crates/adapters/postgres", "crates/adapters/auth", "crates/adapters/jellyfin", "crates/adapters/local-files", "crates/adapters/event-publisher", "crates/presentation", "crates/mcp"] +members = ["crates/domain", "crates/application", "crates/api-types", "crates/infra-wiring", "crates/adapters/adapter-common", "crates/adapters/sqlite", "crates/adapters/auth", "crates/adapters/jellyfin", "crates/adapters/local-files", "crates/adapters/event-publisher", "crates/presentation", "crates/mcp"] exclude = ["k-tv-backend", "k-tv-frontend"] resolver = "2" @@ -33,7 +33,6 @@ api-types = { path = "crates/api-types" } infra-wiring = { path = "crates/infra-wiring" } adapter-common = { path = "crates/adapters/adapter-common" } adapter-sqlite = { path = "crates/adapters/sqlite" } -adapter-postgres = { path = "crates/adapters/postgres" } adapter-auth = { path = "crates/adapters/auth" } adapter-jellyfin = { path = "crates/adapters/jellyfin" } adapter-local-files = { path = "crates/adapters/local-files" } diff --git a/crates/adapters/auth/Cargo.toml b/crates/adapters/auth/Cargo.toml index 2d4ce26..df5f6a0 100644 --- a/crates/adapters/auth/Cargo.toml +++ b/crates/adapters/auth/Cargo.toml @@ -6,7 +6,6 @@ edition = "2024" [features] default = ["jwt"] jwt = ["dep:jsonwebtoken"] -oidc = ["dep:openidconnect", "dep:reqwest", "dep:url"] [dependencies] domain = { workspace = true } @@ -18,10 +17,5 @@ serde_json = { workspace = true } # JWT deps jsonwebtoken = { workspace = true, optional = true } -# OIDC deps (optional) -openidconnect = { version = "4", optional = true } -reqwest = { workspace = true, optional = true } -url = { workspace = true, optional = true } - # Password hashing password-auth = "1" diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs index 5404ec4..2bf7560 100644 --- a/crates/adapters/auth/src/lib.rs +++ b/crates/adapters/auth/src/lib.rs @@ -3,13 +3,7 @@ pub mod password; #[cfg(feature = "jwt")] pub mod jwt; -#[cfg(feature = "oidc")] -pub mod oidc; - pub use password::PasswordAuthService; #[cfg(feature = "jwt")] pub use jwt::{JwtClaims, JwtConfig, JwtError, JwtValidator}; - -#[cfg(feature = "oidc")] -pub use oidc::{OidcService, OidcState, OidcUser}; diff --git a/crates/adapters/auth/src/oidc.rs b/crates/adapters/auth/src/oidc.rs deleted file mode 100644 index 3d12133..0000000 --- a/crates/adapters/auth/src/oidc.rs +++ /dev/null @@ -1,245 +0,0 @@ -use domain::{ - AuthorizationCode, AuthorizationUrlData, ClientId, ClientSecret, CsrfToken, IssuerUrl, - OidcNonce, PkceVerifier, RedirectUrl, ResourceId, -}; -use openidconnect::{ - AccessTokenHash, Client, EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet, - OAuth2TokenResponse, PkceCodeChallenge, Scope, StandardErrorResponse, TokenResponse, - UserInfoClaims, - core::{ - CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreClient, CoreErrorResponseType, - CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreProviderMetadata, - CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, - CoreTokenResponse, - }, - reqwest, -}; -use serde::{Deserialize, Serialize}; - -pub type OidcClient = Client< - EmptyAdditionalClaims, - CoreAuthDisplay, - CoreGenderClaim, - CoreJweContentEncryptionAlgorithm, - CoreJsonWebKey, - CoreAuthPrompt, - StandardErrorResponse, - CoreTokenResponse, - CoreTokenIntrospectionResponse, - CoreRevocableToken, - CoreRevocationErrorResponse, - EndpointSet, - EndpointNotSet, - EndpointNotSet, - EndpointNotSet, - EndpointMaybeSet, - EndpointMaybeSet, ->; - -#[derive(Debug, thiserror::Error)] -pub enum OidcError { - #[error("OIDC discovery failed: {0}")] - Discovery(String), - - #[error("Token exchange failed: {0}")] - TokenExchange(String), - - #[error("ID token verification failed: {0}")] - IdTokenVerification(String), - - #[error("Missing ID token in response")] - MissingIdToken, - - #[error("Invalid access token hash")] - InvalidAccessTokenHash, - - #[error("User has no email address")] - MissingEmail, - - #[error("HTTP client error: {0}")] - Http(String), -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct OidcState { - pub csrf_token: CsrfToken, - pub nonce: OidcNonce, - pub pkce_verifier: PkceVerifier, -} - -#[derive(Debug)] -pub struct OidcUser { - pub subject: String, - pub email: String, -} - -#[derive(Clone)] -pub struct OidcService { - client: OidcClient, - http_client: reqwest::Client, - resource_id: Option, -} - -impl OidcService { - pub async fn new( - issuer: IssuerUrl, - client_id: ClientId, - client_secret: Option, - redirect_url: RedirectUrl, - resource_id: Option, - ) -> Result { - tracing::debug!("OIDC setup: client_id={client_id}, redirect={redirect_url}"); - tracing::debug!( - "OIDC setup: secret={}", - if client_secret.is_some() { - "SET" - } else { - "NONE" - } - ); - - let http_client = reqwest::ClientBuilder::new() - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|e| OidcError::Http(e.to_string()))?; - - let provider_metadata = CoreProviderMetadata::discover_async( - openidconnect::IssuerUrl::new(issuer.as_ref().to_string()) - .map_err(|e| OidcError::Discovery(e.to_string()))?, - &http_client, - ) - .await - .map_err(|e| OidcError::Discovery(e.to_string()))?; - - let oidc_client_id = openidconnect::ClientId::new(client_id.as_ref().to_string()); - let oidc_client_secret = client_secret - .as_ref() - .filter(|s| !s.is_empty()) - .map(|s| openidconnect::ClientSecret::new(s.as_ref().to_string())); - let oidc_redirect_url = - openidconnect::RedirectUrl::new(redirect_url.as_ref().to_string()) - .map_err(|e| OidcError::Discovery(e.to_string()))?; - - let client = CoreClient::from_provider_metadata( - provider_metadata, - oidc_client_id, - oidc_client_secret, - ) - .set_redirect_uri(oidc_redirect_url); - - Ok(Self { - client, - http_client, - resource_id, - }) - } - - pub fn get_authorization_url(&self) -> (AuthorizationUrlData, OidcState) { - let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); - - let (auth_url, csrf_token, nonce) = self - .client - .authorize_url( - CoreAuthenticationFlow::AuthorizationCode, - openidconnect::CsrfToken::new_random, - openidconnect::Nonce::new_random, - ) - .add_scope(Scope::new("profile".to_string())) - .add_scope(Scope::new("email".to_string())) - .set_pkce_challenge(pkce_challenge) - .url(); - - let oidc_state = OidcState { - csrf_token: CsrfToken::new(csrf_token.secret().to_string()), - nonce: OidcNonce::new(nonce.secret().to_string()), - pkce_verifier: PkceVerifier::new(pkce_verifier.secret().to_string()), - }; - - let auth_data = AuthorizationUrlData { - url: auth_url.into(), - csrf_token: oidc_state.csrf_token.clone(), - nonce: oidc_state.nonce.clone(), - pkce_verifier: oidc_state.pkce_verifier.clone(), - }; - - (auth_data, oidc_state) - } - - pub async fn resolve_callback( - &self, - code: AuthorizationCode, - nonce: OidcNonce, - pkce_verifier: PkceVerifier, - ) -> Result { - let oidc_pkce_verifier = - openidconnect::PkceCodeVerifier::new(pkce_verifier.as_ref().to_string()); - let oidc_nonce = openidconnect::Nonce::new(nonce.as_ref().to_string()); - - let token_response = self - .client - .exchange_code(openidconnect::AuthorizationCode::new( - code.as_ref().to_string(), - )) - .map_err(|e| OidcError::TokenExchange(e.to_string()))? - .set_pkce_verifier(oidc_pkce_verifier) - .request_async(&self.http_client) - .await - .map_err(|e| OidcError::TokenExchange(e.to_string()))?; - - let id_token = token_response - .id_token() - .ok_or(OidcError::MissingIdToken)?; - - let mut id_token_verifier = self.client.id_token_verifier().clone(); - - if let Some(resource_id) = &self.resource_id { - let trusted = resource_id.as_ref().to_string(); - id_token_verifier = - id_token_verifier.set_other_audience_verifier_fn(move |aud| aud.as_str() == trusted); - } - - let claims = id_token - .claims(&id_token_verifier, &oidc_nonce) - .map_err(|e| OidcError::IdTokenVerification(e.to_string()))?; - - if let Some(expected_hash) = claims.access_token_hash() { - let actual_hash = AccessTokenHash::from_token( - token_response.access_token(), - id_token - .signing_alg() - .map_err(|e| OidcError::IdTokenVerification(e.to_string()))?, - id_token - .signing_key(&id_token_verifier) - .map_err(|e| OidcError::IdTokenVerification(e.to_string()))?, - ) - .map_err(|e| OidcError::IdTokenVerification(e.to_string()))?; - - if actual_hash != *expected_hash { - return Err(OidcError::InvalidAccessTokenHash); - } - } - - let email = if let Some(email) = claims.email() { - Some(email.as_str().to_string()) - } else { - tracing::debug!("Email missing in ID token, fetching UserInfo"); - - let user_info: UserInfoClaims = self - .client - .user_info(token_response.access_token().clone(), None) - .map_err(|e| OidcError::TokenExchange(e.to_string()))? - .request_async(&self.http_client) - .await - .map_err(|e| OidcError::TokenExchange(e.to_string()))?; - - user_info.email().map(|e| e.as_str().to_string()) - }; - - let email = email.ok_or(OidcError::MissingEmail)?; - - Ok(OidcUser { - subject: claims.subject().to_string(), - email, - }) - } -} diff --git a/crates/adapters/postgres/Cargo.toml b/crates/adapters/postgres/Cargo.toml deleted file mode 100644 index 3c2aa54..0000000 --- a/crates/adapters/postgres/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "adapter-postgres" -version = "0.1.0" -edition = "2024" - -[dependencies] -domain = { workspace = true } -adapter-common = { workspace = true } -infra-wiring = { workspace = true, features = ["postgres"] } -async-trait = { workspace = true } -sqlx = { workspace = true, features = ["postgres"] } -chrono = { workspace = true } -uuid = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tracing = { workspace = true } diff --git a/crates/adapters/postgres/src/activity.rs b/crates/adapters/postgres/src/activity.rs deleted file mode 100644 index 2ca5731..0000000 --- a/crates/adapters/postgres/src/activity.rs +++ /dev/null @@ -1,83 +0,0 @@ -use async_trait::async_trait; -use chrono::Utc; -use sqlx::PgPool; -use uuid::Uuid; - -use adapter_common::{map_sqlx_error, parse_dt, parse_uuid}; -use domain::{ - ports::activity::{ActivityLogCommand, ActivityLogQuery}, - ActivityEvent, ActivityEventId, ChannelId, DomainResult, -}; - -pub struct PgActivityLog { - pool: PgPool, -} - -impl PgActivityLog { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl ActivityLogCommand for PgActivityLog { - async fn log( - &self, - event_type: &str, - detail: &str, - channel_id: Option, - ) -> DomainResult<()> { - let id = Uuid::new_v4().to_string(); - let timestamp = Utc::now().to_rfc3339(); - let channel_id_str = channel_id.map(|id| id.value().to_string()); - - sqlx::query( - "INSERT INTO activity_log (id, timestamp, event_type, detail, channel_id) VALUES ($1, $2, $3, $4, $5)", - ) - .bind(&id) - .bind(×tamp) - .bind(event_type) - .bind(detail) - .bind(&channel_id_str) - .execute(&self.pool) - .await - .map_err(map_sqlx_error)?; - - Ok(()) - } -} - -#[async_trait] -impl ActivityLogQuery for PgActivityLog { - async fn recent(&self, limit: u32) -> DomainResult> { - let rows: Vec<(String, String, String, String, Option)> = sqlx::query_as( - "SELECT id, timestamp, event_type, detail, channel_id FROM activity_log ORDER BY timestamp DESC LIMIT $1", - ) - .bind(limit as i64) - .fetch_all(&self.pool) - .await - .map_err(map_sqlx_error)?; - - let mut events = Vec::with_capacity(rows.len()); - for (id_str, ts_str, event_type, detail, channel_id_str) in rows { - let Ok(id) = parse_uuid(&id_str, "activity id") else { - continue; - }; - let Ok(timestamp) = parse_dt(&ts_str) else { - continue; - }; - let channel_id = channel_id_str - .and_then(|s| Uuid::parse_str(&s).ok()) - .map(ChannelId::from_uuid); - events.push(ActivityEvent::from_persistence( - ActivityEventId::from_uuid(id), - timestamp, - event_type, - detail, - channel_id, - )); - } - - Ok(events) - } -} diff --git a/crates/adapters/postgres/src/channel.rs b/crates/adapters/postgres/src/channel.rs deleted file mode 100644 index a620dc7..0000000 --- a/crates/adapters/postgres/src/channel.rs +++ /dev/null @@ -1,329 +0,0 @@ -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use sqlx::{PgPool, Row}; -use uuid::Uuid; - -use adapter_common::{ - map_sqlx_error, parse_dt, parse_enum_or_default, parse_recycle_policy, parse_schedule_config, - parse_uuid, serialize_enum_as_string, -}; -use domain::{ - ports::channel::{ChannelCommand, ChannelQuery}, - Channel, ChannelConfigSnapshot, ChannelId, ChannelRow as DomainChannelRow, DomainError, - DomainResult, ScheduleConfig, SnapshotId, UserId, -}; - -pub struct PgChannelRepository { - pool: PgPool, -} - -impl PgChannelRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, created_at, updated_at"; - -#[derive(Debug, sqlx::FromRow)] -struct ChannelRow { - id: String, - owner_id: String, - name: String, - description: Option, - timezone: String, - schedule_config: String, - recycle_policy: String, - auto_schedule: bool, - access_mode: String, - access_password_hash: Option, - logo: Option, - logo_position: String, - logo_opacity: f32, - webhook_url: Option, - webhook_poll_interval_secs: i64, - webhook_body_template: Option, - webhook_headers: Option, - created_at: String, - updated_at: String, -} - -impl ChannelRow { - fn into_channel(self) -> DomainResult { - Ok(Channel::from_persistence(DomainChannelRow { - id: ChannelId::from_uuid(parse_uuid(&self.id, "channel id")?), - owner_id: UserId::from_uuid(parse_uuid(&self.owner_id, "owner id")?), - name: self.name, - description: self.description, - timezone: self.timezone, - schedule_config: parse_schedule_config(&self.schedule_config)?, - recycle_policy: parse_recycle_policy(&self.recycle_policy)?, - auto_schedule: self.auto_schedule, - access_mode: parse_enum_or_default(self.access_mode), - access_password_hash: self.access_password_hash, - logo: self.logo, - logo_position: parse_enum_or_default(self.logo_position), - logo_opacity: self.logo_opacity, - webhook_url: self.webhook_url, - webhook_poll_interval_secs: self.webhook_poll_interval_secs as u32, - webhook_body_template: self.webhook_body_template, - webhook_headers: self.webhook_headers, - created_at: parse_dt(&self.created_at)?, - updated_at: parse_dt(&self.updated_at)?, - })) - } -} - -fn map_snapshot_row( - row: &sqlx::postgres::PgRow, - channel_id: ChannelId, -) -> DomainResult { - let id_str: String = row.get("id"); - let id = SnapshotId::from_uuid(parse_uuid(&id_str, "snapshot id")?); - let config_json: String = row.get("config_json"); - let config = parse_schedule_config(&config_json)?; - let version_num: i64 = row.get("version_num"); - let label: Option = row.get("label"); - let created_at_str: String = row.get("created_at"); - let created_at: DateTime = parse_dt(&created_at_str)?; - - Ok(ChannelConfigSnapshot::from_persistence( - id, - channel_id, - config, - version_num, - label, - created_at, - )) -} - -#[async_trait] -impl ChannelCommand for PgChannelRepository { - async fn save(&self, channel: &Channel) -> DomainResult<()> { - let schedule_config = serde_json::to_string(channel.schedule_config()) - .map_err(|e| DomainError::RepositoryError(format!("serialize schedule_config: {e}")))?; - let recycle_policy = serde_json::to_string(channel.recycle_policy()) - .map_err(|e| DomainError::RepositoryError(format!("serialize recycle_policy: {e}")))?; - let access_mode = serialize_enum_as_string(channel.access_mode(), "public"); - let logo_position = serialize_enum_as_string(channel.logo_position(), "top_right"); - - sqlx::query( - r#" - INSERT INTO channels - (id, owner_id, name, description, timezone, schedule_config, recycle_policy, - auto_schedule, access_mode, access_password_hash, logo, logo_position, - logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, - webhook_headers, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) - ON CONFLICT(id) DO UPDATE SET - name = EXCLUDED.name, - description = EXCLUDED.description, - timezone = EXCLUDED.timezone, - schedule_config = EXCLUDED.schedule_config, - recycle_policy = EXCLUDED.recycle_policy, - auto_schedule = EXCLUDED.auto_schedule, - access_mode = EXCLUDED.access_mode, - access_password_hash = EXCLUDED.access_password_hash, - logo = EXCLUDED.logo, - logo_position = EXCLUDED.logo_position, - logo_opacity = EXCLUDED.logo_opacity, - webhook_url = EXCLUDED.webhook_url, - webhook_poll_interval_secs = EXCLUDED.webhook_poll_interval_secs, - webhook_body_template = EXCLUDED.webhook_body_template, - webhook_headers = EXCLUDED.webhook_headers, - updated_at = EXCLUDED.updated_at - "#, - ) - .bind(channel.id().value().to_string()) - .bind(channel.owner_id().value().to_string()) - .bind(channel.name()) - .bind(channel.description()) - .bind(channel.timezone()) - .bind(&schedule_config) - .bind(&recycle_policy) - .bind(channel.auto_schedule()) - .bind(&access_mode) - .bind(channel.access_password_hash()) - .bind(channel.logo()) - .bind(&logo_position) - .bind(channel.logo_opacity()) - .bind(channel.webhook_url()) - .bind(channel.webhook_poll_interval_secs() as i64) - .bind(channel.webhook_body_template()) - .bind(channel.webhook_headers()) - .bind(channel.created_at().to_rfc3339()) - .bind(channel.updated_at().to_rfc3339()) - .execute(&self.pool) - .await - .map_err(map_sqlx_error)?; - - Ok(()) - } - - async fn delete(&self, id: ChannelId) -> DomainResult<()> { - sqlx::query("DELETE FROM channels WHERE id = $1") - .bind(id.value().to_string()) - .execute(&self.pool) - .await - .map_err(map_sqlx_error)?; - Ok(()) - } - - async fn save_config_snapshot( - &self, - channel_id: ChannelId, - config: &ScheduleConfig, - label: Option, - ) -> DomainResult { - let id = Uuid::new_v4(); - let now = Utc::now(); - let config_json = serde_json::to_string(config) - .map_err(|e| DomainError::RepositoryError(e.to_string()))?; - - let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; - - let version_num: i64 = sqlx::query_scalar( - "SELECT COALESCE(MAX(version_num), 0) + 1 FROM channel_config_snapshots WHERE channel_id = $1", - ) - .bind(channel_id.value().to_string()) - .fetch_one(&mut *tx) - .await - .map_err(map_sqlx_error)?; - - sqlx::query( - "INSERT INTO channel_config_snapshots (id, channel_id, config_json, version_num, label, created_at) - VALUES ($1, $2, $3, $4, $5, $6)", - ) - .bind(id.to_string()) - .bind(channel_id.value().to_string()) - .bind(&config_json) - .bind(version_num) - .bind(&label) - .bind(now.to_rfc3339()) - .execute(&mut *tx) - .await - .map_err(map_sqlx_error)?; - - tx.commit().await.map_err(map_sqlx_error)?; - - Ok(ChannelConfigSnapshot::from_persistence( - SnapshotId::from_uuid(id), - channel_id, - config.clone(), - version_num, - label, - now, - )) - } - - async fn patch_config_snapshot_label( - &self, - channel_id: ChannelId, - snapshot_id: SnapshotId, - label: Option, - ) -> DomainResult> { - let updated = sqlx::query( - "UPDATE channel_config_snapshots SET label = $1 WHERE id = $2 AND channel_id = $3 RETURNING id", - ) - .bind(&label) - .bind(snapshot_id.value().to_string()) - .bind(channel_id.value().to_string()) - .fetch_optional(&self.pool) - .await - .map_err(map_sqlx_error)?; - - if updated.is_none() { - return Ok(None); - } - self.get_config_snapshot(channel_id, snapshot_id).await - } -} - -#[async_trait] -impl ChannelQuery for PgChannelRepository { - async fn find_by_id(&self, id: ChannelId) -> DomainResult> { - let sql = format!("SELECT {SELECT_COLS} FROM channels WHERE id = $1"); - let row: Option = sqlx::query_as(&sql) - .bind(id.value().to_string()) - .fetch_optional(&self.pool) - .await - .map_err(map_sqlx_error)?; - - row.map(ChannelRow::into_channel).transpose() - } - - async fn find_by_owner(&self, owner_id: UserId) -> DomainResult> { - let sql = format!( - "SELECT {SELECT_COLS} FROM channels WHERE owner_id = $1 ORDER BY created_at ASC" - ); - let rows: Vec = sqlx::query_as(&sql) - .bind(owner_id.value().to_string()) - .fetch_all(&self.pool) - .await - .map_err(map_sqlx_error)?; - - rows.into_iter().map(ChannelRow::into_channel).collect() - } - - async fn find_all(&self) -> DomainResult> { - let sql = format!("SELECT {SELECT_COLS} FROM channels ORDER BY created_at ASC"); - let rows: Vec = sqlx::query_as(&sql) - .fetch_all(&self.pool) - .await - .map_err(map_sqlx_error)?; - - rows.into_iter().map(ChannelRow::into_channel).collect() - } - - async fn find_auto_schedule_enabled(&self) -> DomainResult> { - let sql = format!( - "SELECT {SELECT_COLS} FROM channels WHERE auto_schedule = TRUE ORDER BY created_at ASC" - ); - let rows: Vec = sqlx::query_as(&sql) - .fetch_all(&self.pool) - .await - .map_err(map_sqlx_error)?; - - rows.into_iter().map(ChannelRow::into_channel).collect() - } - - async fn list_config_snapshots( - &self, - channel_id: ChannelId, - ) -> DomainResult> { - let rows = sqlx::query( - "SELECT id, config_json, version_num, label, created_at - FROM channel_config_snapshots WHERE channel_id = $1 - ORDER BY version_num DESC", - ) - .bind(channel_id.value().to_string()) - .fetch_all(&self.pool) - .await - .map_err(map_sqlx_error)?; - - rows.iter() - .map(|row| map_snapshot_row(row, channel_id)) - .collect() - } - - async fn get_config_snapshot( - &self, - channel_id: ChannelId, - snapshot_id: SnapshotId, - ) -> DomainResult> { - let row = sqlx::query( - "SELECT id, config_json, version_num, label, created_at - FROM channel_config_snapshots WHERE id = $1 AND channel_id = $2", - ) - .bind(snapshot_id.value().to_string()) - .bind(channel_id.value().to_string()) - .fetch_optional(&self.pool) - .await - .map_err(map_sqlx_error)?; - - match row { - None => Ok(None), - Some(row) => Ok(Some(map_snapshot_row(&row, channel_id)?)), - } - } -} diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs deleted file mode 100644 index 8de27de..0000000 --- a/crates/adapters/postgres/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -pub mod activity; -pub mod channel; -pub mod library; -pub mod provider_config; -pub mod schedule; -pub mod settings; -pub mod transcode; -pub mod user; -pub mod wire; - -pub use wire::{wire, PostgresWireOutput}; diff --git a/crates/adapters/postgres/src/library.rs b/crates/adapters/postgres/src/library.rs deleted file mode 100644 index 673335e..0000000 --- a/crates/adapters/postgres/src/library.rs +++ /dev/null @@ -1,530 +0,0 @@ -use async_trait::async_trait; -use sqlx::PgPool; - -use adapter_common::{content_type_str, parse_content_type, parse_genres_blob}; -use domain::{ - ports::library::{LibraryCommand, LibraryQuery}, - ContentType, DomainError, DomainResult, LibraryCollection, LibraryItem, - LibraryItemRow as DomainLibraryItemRow, LibrarySearchFilter, LibrarySyncLogEntry, - LibrarySyncResult, SeasonSummary, ShowSummary, -}; - -pub struct PgLibraryRepository { - pool: PgPool, -} - -impl PgLibraryRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[derive(sqlx::FromRow)] -struct LibraryItemRow { - id: String, - provider_id: String, - external_id: String, - title: String, - content_type: String, - duration_secs: i64, - series_name: Option, - season_number: Option, - episode_number: Option, - year: Option, - genres: String, - tags: String, - collection_id: Option, - collection_name: Option, - collection_type: Option, - thumbnail_url: Option, - synced_at: String, -} - -impl LibraryItemRow { - fn into_library_item(self) -> LibraryItem { - LibraryItem::from_persistence(DomainLibraryItemRow { - id: self.id, - provider_id: self.provider_id, - external_id: self.external_id, - title: self.title, - content_type: parse_content_type(&self.content_type), - duration_secs: self.duration_secs as u32, - series_name: self.series_name, - season_number: self.season_number.map(|n| n as u32), - episode_number: self.episode_number.map(|n| n as u32), - year: self.year.map(|n| n as u16), - genres: serde_json::from_str(&self.genres).unwrap_or_default(), - tags: serde_json::from_str(&self.tags).unwrap_or_default(), - collection_id: self.collection_id, - collection_name: self.collection_name, - collection_type: self.collection_type, - thumbnail_url: self.thumbnail_url, - synced_at: self.synced_at, - }) - } -} - -#[derive(sqlx::FromRow)] -struct SyncLogRow { - id: i64, - provider_id: String, - started_at: String, - finished_at: Option, - items_found: i64, - status: String, - error_msg: Option, -} - -#[derive(sqlx::FromRow)] -struct ShowSummaryRow { - series_name: String, - episode_count: i64, - season_count: i64, - thumbnail_url: Option, - genres_blob: String, -} - -#[derive(sqlx::FromRow)] -struct SeasonSummaryRow { - season_number: i64, - episode_count: i64, - thumbnail_url: Option, -} - -#[async_trait] -impl LibraryCommand for PgLibraryRepository { - async fn upsert_items(&self, _provider_id: &str, items: Vec) -> DomainResult<()> { - let mut tx = self - .pool - .begin() - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - for item in items { - sqlx::query( - "INSERT INTO library_items - (id, provider_id, external_id, title, content_type, duration_secs, - series_name, season_number, episode_number, year, genres, tags, - collection_id, collection_name, collection_type, thumbnail_url, synced_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) - ON CONFLICT(id) DO UPDATE SET - provider_id = EXCLUDED.provider_id, - external_id = EXCLUDED.external_id, - title = EXCLUDED.title, - content_type = EXCLUDED.content_type, - duration_secs = EXCLUDED.duration_secs, - series_name = EXCLUDED.series_name, - season_number = EXCLUDED.season_number, - episode_number = EXCLUDED.episode_number, - year = EXCLUDED.year, - genres = EXCLUDED.genres, - tags = EXCLUDED.tags, - collection_id = EXCLUDED.collection_id, - collection_name = EXCLUDED.collection_name, - collection_type = EXCLUDED.collection_type, - thumbnail_url = EXCLUDED.thumbnail_url, - synced_at = EXCLUDED.synced_at", - ) - .bind(item.id()) - .bind(item.provider_id()) - .bind(item.external_id()) - .bind(item.title()) - .bind(content_type_str(item.content_type())) - .bind(item.duration_secs() as i64) - .bind(item.series_name()) - .bind(item.season_number().map(|n| n as i64)) - .bind(item.episode_number().map(|n| n as i64)) - .bind(item.year().map(|n| n as i64)) - .bind(serde_json::to_string(item.genres()).unwrap_or_default()) - .bind(serde_json::to_string(item.tags()).unwrap_or_default()) - .bind(item.collection_id()) - .bind(item.collection_name()) - .bind(item.collection_type()) - .bind(item.thumbnail_url()) - .bind(item.synced_at()) - .execute(&mut *tx) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - } - - tx.commit() - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string())) - } - - async fn clear_provider(&self, provider_id: &str) -> DomainResult<()> { - sqlx::query("DELETE FROM library_items WHERE provider_id = $1") - .bind(provider_id) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(|e| DomainError::InfrastructureError(e.to_string())) - } - - async fn log_sync_start(&self, provider_id: &str) -> DomainResult { - let now = chrono::Utc::now().to_rfc3339(); - let id = sqlx::query_scalar::<_, i64>( - "INSERT INTO library_sync_log (provider_id, started_at, status) - VALUES ($1, $2, 'running') RETURNING id", - ) - .bind(provider_id) - .bind(&now) - .fetch_one(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(id) - } - - async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()> { - let now = chrono::Utc::now().to_rfc3339(); - let status = if result.error().is_none() { - "done" - } else { - "error" - }; - sqlx::query( - "UPDATE library_sync_log - SET finished_at = $1, items_found = $2, status = $3, error_msg = $4 - WHERE id = $5", - ) - .bind(&now) - .bind(result.items_found() as i64) - .bind(status) - .bind(result.error()) - .bind(log_id) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(|e| DomainError::InfrastructureError(e.to_string())) - } -} - -#[async_trait] -impl LibraryQuery for PgLibraryRepository { - async fn search( - &self, - filter: &LibrarySearchFilter, - ) -> DomainResult<(Vec, u32)> { - let mut conditions: Vec = vec![]; - - if let Some(p) = filter.provider_id() { - conditions.push(format!("provider_id = '{}'", p.replace('\'', "''"))); - } - if let Some(ct) = filter.content_type() { - conditions.push(format!("content_type = '{}'", content_type_str(ct))); - } - if let Some(st) = filter.search_term() { - conditions.push(format!("title ILIKE '%{}%'", st.replace('\'', "''"))); - } - if let Some(cid) = filter.collection_id() { - conditions.push(format!("collection_id = '{}'", cid.replace('\'', "''"))); - } - if let Some(decade) = filter.decade() { - let end = decade + 10; - conditions.push(format!("year >= {} AND year < {}", decade, end)); - } - if let Some(min) = filter.min_duration_secs() { - conditions.push(format!("duration_secs >= {}", min)); - } - if let Some(max) = filter.max_duration_secs() { - conditions.push(format!("duration_secs <= {}", max)); - } - if !filter.series_names().is_empty() { - let quoted: Vec = filter - .series_names() - .iter() - .map(|s| format!("'{}'", s.replace('\'', "''"))) - .collect(); - conditions.push(format!("series_name IN ({})", quoted.join(","))); - } - if !filter.genres().is_empty() { - let genre_conditions: Vec = filter - .genres() - .iter() - .map(|g| { - format!( - "EXISTS (SELECT 1 FROM jsonb_array_elements_text(library_items.genres::jsonb) je WHERE je = '{}')", - g.replace('\'', "''") - ) - }) - .collect(); - conditions.push(format!("({})", genre_conditions.join(" OR "))); - } - if let Some(sn) = filter.season_number() { - conditions.push(format!("season_number = {}", sn)); - } - - let where_clause = if conditions.is_empty() { - String::new() - } else { - format!("WHERE {}", conditions.join(" AND ")) - }; - - let count_sql = format!("SELECT COUNT(*) FROM library_items {}", where_clause); - let total: i64 = sqlx::query_scalar(&count_sql) - .fetch_one(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - let items_sql = format!( - "SELECT * FROM library_items {} ORDER BY title ASC LIMIT {} OFFSET {}", - where_clause, - filter.limit(), - filter.offset() - ); - - let rows = sqlx::query_as::<_, LibraryItemRow>(&items_sql) - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - Ok(( - rows.into_iter() - .map(LibraryItemRow::into_library_item) - .collect(), - total as u32, - )) - } - - async fn get_by_id(&self, id: &str) -> DomainResult> { - let row = sqlx::query_as::<_, LibraryItemRow>( - "SELECT * FROM library_items WHERE id = $1", - ) - .bind(id) - .fetch_optional(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(row.map(LibraryItemRow::into_library_item)) - } - - async fn list_collections( - &self, - provider_id: Option<&str>, - ) -> DomainResult> { - let rows: Vec<(String, Option, Option)> = if let Some(p) = provider_id { - sqlx::query_as( - "SELECT DISTINCT collection_id, collection_name, collection_type - FROM library_items WHERE collection_id IS NOT NULL AND provider_id = $1 - ORDER BY collection_name ASC", - ) - .bind(p) - .fetch_all(&self.pool) - .await - } else { - sqlx::query_as( - "SELECT DISTINCT collection_id, collection_name, collection_type - FROM library_items WHERE collection_id IS NOT NULL - ORDER BY collection_name ASC", - ) - .fetch_all(&self.pool) - .await - } - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - Ok(rows - .into_iter() - .map(|(id, name, ct)| { - LibraryCollection::from_persistence(id, name.unwrap_or_default(), ct) - }) - .collect()) - } - - async fn list_series(&self, provider_id: Option<&str>) -> DomainResult> { - let rows: Vec<(String,)> = if let Some(p) = provider_id { - sqlx::query_as( - "SELECT DISTINCT series_name FROM library_items - WHERE series_name IS NOT NULL AND provider_id = $1 ORDER BY series_name ASC", - ) - .bind(p) - .fetch_all(&self.pool) - .await - } else { - sqlx::query_as( - "SELECT DISTINCT series_name FROM library_items - WHERE series_name IS NOT NULL ORDER BY series_name ASC", - ) - .fetch_all(&self.pool) - .await - } - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - Ok(rows.into_iter().map(|(s,)| s).collect()) - } - - async fn list_genres( - &self, - content_type: Option<&ContentType>, - provider_id: Option<&str>, - ) -> DomainResult> { - let sql = match (content_type, provider_id) { - (Some(ct), Some(p)) => format!( - "SELECT DISTINCT je AS value FROM library_items li, \ - LATERAL jsonb_array_elements_text(li.genres::jsonb) je \ - WHERE li.content_type = '{}' AND li.provider_id = '{}' ORDER BY value ASC", - content_type_str(ct), - p.replace('\'', "''") - ), - (Some(ct), None) => format!( - "SELECT DISTINCT je AS value FROM library_items li, \ - LATERAL jsonb_array_elements_text(li.genres::jsonb) je \ - WHERE li.content_type = '{}' ORDER BY value ASC", - content_type_str(ct) - ), - (None, Some(p)) => format!( - "SELECT DISTINCT je AS value FROM library_items li, \ - LATERAL jsonb_array_elements_text(li.genres::jsonb) je \ - WHERE li.provider_id = '{}' ORDER BY value ASC", - p.replace('\'', "''") - ), - (None, None) => { - "SELECT DISTINCT je AS value FROM library_items li, \ - LATERAL jsonb_array_elements_text(li.genres::jsonb) je \ - ORDER BY value ASC" - .to_string() - } - }; - let rows: Vec<(String,)> = sqlx::query_as(&sql) - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(rows.into_iter().map(|(s,)| s).collect()) - } - - async fn latest_sync_status(&self) -> DomainResult> { - let rows = sqlx::query_as::<_, SyncLogRow>( - "SELECT * FROM library_sync_log - WHERE id IN ( - SELECT MAX(id) FROM library_sync_log GROUP BY provider_id - ) - ORDER BY started_at DESC", - ) - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - Ok(rows - .into_iter() - .map(|r| { - LibrarySyncLogEntry::from_persistence( - r.id, - r.provider_id, - r.started_at, - r.finished_at, - r.items_found as u32, - r.status, - r.error_msg, - ) - }) - .collect()) - } - - async fn is_sync_running(&self, provider_id: &str) -> DomainResult { - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM library_sync_log WHERE provider_id = $1 AND status = 'running'", - ) - .bind(provider_id) - .fetch_one(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(count > 0) - } - - async fn list_shows( - &self, - provider_id: Option<&str>, - search_term: Option<&str>, - genres: &[String], - ) -> DomainResult> { - let mut conditions = vec![ - "content_type = 'episode'".to_string(), - "series_name IS NOT NULL".to_string(), - ]; - if let Some(p) = provider_id { - conditions.push(format!("provider_id = '{}'", p.replace('\'', "''"))); - } - if let Some(st) = search_term { - let escaped = st.replace('\'', "''"); - conditions.push(format!( - "(title ILIKE '%{escaped}%' OR series_name ILIKE '%{escaped}%')" - )); - } - if !genres.is_empty() { - let genre_conditions: Vec = genres - .iter() - .map(|g| { - format!( - "EXISTS (SELECT 1 FROM jsonb_array_elements_text(library_items.genres::jsonb) je WHERE je = '{}')", - g.replace('\'', "''") - ) - }) - .collect(); - conditions.push(format!("({})", genre_conditions.join(" OR "))); - } - - let where_clause = format!("WHERE {}", conditions.join(" AND ")); - let sql = format!( - "SELECT series_name, COUNT(*) AS episode_count, \ - COUNT(DISTINCT season_number) AS season_count, \ - MAX(thumbnail_url) AS thumbnail_url, \ - STRING_AGG(genres, ',') AS genres_blob \ - FROM library_items {} GROUP BY series_name ORDER BY series_name ASC", - where_clause - ); - - let rows = sqlx::query_as::<_, ShowSummaryRow>(&sql) - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - Ok(rows - .into_iter() - .map(|r| { - let genres = parse_genres_blob(&r.genres_blob); - ShowSummary::from_persistence( - r.series_name, - r.episode_count as u32, - r.season_count as u32, - r.thumbnail_url, - genres, - ) - }) - .collect()) - } - - async fn list_seasons( - &self, - series_name: &str, - provider_id: Option<&str>, - ) -> DomainResult> { - let mut conditions = vec![ - format!("series_name = '{}'", series_name.replace('\'', "''")), - "content_type = 'episode'".to_string(), - ]; - if let Some(p) = provider_id { - conditions.push(format!("provider_id = '{}'", p.replace('\'', "''"))); - } - let where_clause = format!("WHERE {}", conditions.join(" AND ")); - let sql = format!( - "SELECT season_number, COUNT(*) AS episode_count, \ - MAX(thumbnail_url) AS thumbnail_url \ - FROM library_items {} GROUP BY season_number ORDER BY season_number ASC", - where_clause - ); - - let rows = sqlx::query_as::<_, SeasonSummaryRow>(&sql) - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - Ok(rows - .into_iter() - .map(|r| { - SeasonSummary::from_persistence( - r.season_number as u32, - r.episode_count as u32, - r.thumbnail_url, - ) - }) - .collect()) - } -} diff --git a/crates/adapters/postgres/src/provider_config.rs b/crates/adapters/postgres/src/provider_config.rs deleted file mode 100644 index 6b1e737..0000000 --- a/crates/adapters/postgres/src/provider_config.rs +++ /dev/null @@ -1,90 +0,0 @@ -use async_trait::async_trait; -use sqlx::PgPool; - -use adapter_common::map_sqlx_error; -use domain::{ - ports::provider_config::{ProviderConfigCommand, ProviderConfigQuery}, - DomainResult, ProviderConfigRow, -}; - -pub struct PgProviderConfig { - pool: PgPool, -} - -impl PgProviderConfig { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl ProviderConfigCommand for PgProviderConfig { - async fn upsert(&self, row: &ProviderConfigRow) -> DomainResult<()> { - sqlx::query( - r#"INSERT INTO provider_configs (id, provider_type, config_json, enabled, updated_at) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT(id) DO UPDATE SET - provider_type = EXCLUDED.provider_type, - config_json = EXCLUDED.config_json, - enabled = EXCLUDED.enabled, - updated_at = EXCLUDED.updated_at"#, - ) - .bind(row.id()) - .bind(row.provider_type()) - .bind(row.config_json()) - .bind(row.enabled()) - .bind(row.updated_at()) - .execute(&self.pool) - .await - .map_err(map_sqlx_error)?; - Ok(()) - } - - async fn delete(&self, id: &str) -> DomainResult<()> { - sqlx::query("DELETE FROM provider_configs WHERE id = $1") - .bind(id) - .execute(&self.pool) - .await - .map_err(map_sqlx_error)?; - Ok(()) - } -} - -#[async_trait] -impl ProviderConfigQuery for PgProviderConfig { - async fn get_all(&self) -> DomainResult> { - let rows: Vec<(String, String, String, bool, String)> = sqlx::query_as( - "SELECT id, provider_type, config_json, enabled, updated_at FROM provider_configs", - ) - .fetch_all(&self.pool) - .await - .map_err(map_sqlx_error)?; - - Ok(rows - .into_iter() - .map(|(id, provider_type, config_json, enabled, updated_at)| { - ProviderConfigRow::from_persistence( - id, - provider_type, - config_json, - enabled, - updated_at, - ) - }) - .collect()) - } - - async fn get_by_id(&self, id: &str) -> DomainResult> { - let row: Option<(String, String, String, bool, String)> = sqlx::query_as( - "SELECT id, provider_type, config_json, enabled, updated_at FROM provider_configs WHERE id = $1", - ) - .bind(id) - .fetch_optional(&self.pool) - .await - .map_err(map_sqlx_error)?; - - Ok(row.map(|(id, provider_type, config_json, enabled, updated_at)| { - ProviderConfigRow::from_persistence(id, provider_type, config_json, enabled, updated_at) - })) - } -} diff --git a/crates/adapters/postgres/src/schedule.rs b/crates/adapters/postgres/src/schedule.rs deleted file mode 100644 index 0256a10..0000000 --- a/crates/adapters/postgres/src/schedule.rs +++ /dev/null @@ -1,349 +0,0 @@ -use std::collections::HashMap; - -use async_trait::async_trait; -use sqlx::PgPool; - -use adapter_common::{map_sqlx_error, parse_dt, parse_json, parse_uuid}; -use domain::{ - ports::schedule::{ScheduleCommand, ScheduleQuery}, - BlockId, ChannelId, DomainError, DomainResult, GeneratedSchedule, MediaItem, MediaItemId, - PlaybackRecord, PlaybackRecordId, ScheduleId, ScheduledSlot, SlotId, -}; - -pub struct PgScheduleRepository { - pool: PgPool, -} - -impl PgScheduleRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[derive(Debug, sqlx::FromRow)] -struct ScheduleRow { - id: String, - channel_id: String, - valid_from: String, - valid_until: String, - generation: i64, -} - -#[derive(Debug, sqlx::FromRow)] -struct SlotRow { - id: String, - #[sqlx(rename = "schedule_id")] - _schedule_id: String, - start_at: String, - end_at: String, - item: String, - source_block_id: String, -} - -#[derive(Debug, sqlx::FromRow)] -struct LastSlotRow { - source_block_id: String, - item: String, -} - -#[derive(Debug, sqlx::FromRow)] -struct PlaybackRecordRow { - id: String, - channel_id: String, - item_id: String, - played_at: String, - generation: i64, -} - -fn map_slot_row(row: SlotRow) -> DomainResult { - let id = SlotId::from_uuid(parse_uuid(&row.id, "slot id")?); - let source_block_id = BlockId::from_uuid(parse_uuid(&row.source_block_id, "block id")?); - let item: MediaItem = parse_json(&row.item, "slot item")?; - - Ok(ScheduledSlot::from_persistence( - id, - parse_dt(&row.start_at)?, - parse_dt(&row.end_at)?, - item, - source_block_id, - )) -} - -fn map_schedule(row: ScheduleRow, slot_rows: Vec) -> DomainResult { - let id = ScheduleId::from_uuid(parse_uuid(&row.id, "schedule id")?); - let channel_id = ChannelId::from_uuid(parse_uuid(&row.channel_id, "channel id")?); - let slots: Result, _> = slot_rows.into_iter().map(map_slot_row).collect(); - - Ok(GeneratedSchedule::from_persistence( - id, - channel_id, - parse_dt(&row.valid_from)?, - parse_dt(&row.valid_until)?, - row.generation as u32, - slots?, - )) -} - -fn map_playback_row(row: PlaybackRecordRow) -> DomainResult { - let id = PlaybackRecordId::from_uuid(parse_uuid(&row.id, "playback record id")?); - let channel_id = ChannelId::from_uuid(parse_uuid(&row.channel_id, "channel id")?); - - Ok(PlaybackRecord::from_persistence( - id, - channel_id, - MediaItemId::new(row.item_id), - parse_dt(&row.played_at)?, - row.generation as u32, - )) -} - -impl PgScheduleRepository { - async fn fetch_slots(&self, schedule_id: &str) -> DomainResult> { - sqlx::query_as( - "SELECT id, schedule_id, start_at, end_at, item, source_block_id \ - FROM scheduled_slots WHERE schedule_id = $1 ORDER BY start_at", - ) - .bind(schedule_id) - .fetch_all(&self.pool) - .await - .map_err(map_sqlx_error) - } -} - -#[async_trait] -impl ScheduleCommand for PgScheduleRepository { - async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()> { - sqlx::query( - r#" - INSERT INTO generated_schedules (id, channel_id, valid_from, valid_until, generation) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT(id) DO UPDATE SET - valid_from = EXCLUDED.valid_from, - valid_until = EXCLUDED.valid_until, - generation = EXCLUDED.generation - "#, - ) - .bind(schedule.id().value().to_string()) - .bind(schedule.channel_id().value().to_string()) - .bind(schedule.valid_from().to_rfc3339()) - .bind(schedule.valid_until().to_rfc3339()) - .bind(schedule.generation() as i64) - .execute(&self.pool) - .await - .map_err(map_sqlx_error)?; - - sqlx::query("DELETE FROM scheduled_slots WHERE schedule_id = $1") - .bind(schedule.id().value().to_string()) - .execute(&self.pool) - .await - .map_err(map_sqlx_error)?; - - for slot in schedule.slots() { - let item_json = serde_json::to_string(slot.item()) - .map_err(|e| DomainError::RepositoryError(format!("serialize slot item: {e}")))?; - - sqlx::query( - "INSERT INTO scheduled_slots (id, schedule_id, start_at, end_at, item, source_block_id) - VALUES ($1, $2, $3, $4, $5, $6)", - ) - .bind(slot.id().value().to_string()) - .bind(schedule.id().value().to_string()) - .bind(slot.start_at().to_rfc3339()) - .bind(slot.end_at().to_rfc3339()) - .bind(&item_json) - .bind(slot.source_block_id().value().to_string()) - .execute(&self.pool) - .await - .map_err(map_sqlx_error)?; - } - - Ok(()) - } - - async fn save_playback_record(&self, record: &PlaybackRecord) -> DomainResult<()> { - sqlx::query( - r#" - INSERT INTO playback_records (id, channel_id, item_id, played_at, generation) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT(id) DO NOTHING - "#, - ) - .bind(record.id().to_string()) - .bind(record.channel_id().value().to_string()) - .bind(record.item_id().value()) - .bind(record.played_at().to_rfc3339()) - .bind(record.generation() as i64) - .execute(&self.pool) - .await - .map_err(map_sqlx_error)?; - Ok(()) - } - - async fn delete_schedules_after( - &self, - channel_id: ChannelId, - target_generation: u32, - ) -> DomainResult<()> { - let ch = channel_id.value().to_string(); - let target_gen = target_generation as i64; - - sqlx::query("DELETE FROM playback_records WHERE channel_id = $1 AND generation > $2") - .bind(&ch) - .bind(target_gen) - .execute(&self.pool) - .await - .map_err(map_sqlx_error)?; - - sqlx::query("DELETE FROM generated_schedules WHERE channel_id = $1 AND generation > $2") - .bind(&ch) - .bind(target_gen) - .execute(&self.pool) - .await - .map_err(map_sqlx_error)?; - - Ok(()) - } -} - -#[async_trait] -impl ScheduleQuery for PgScheduleRepository { - async fn find_active( - &self, - channel_id: ChannelId, - at: chrono::DateTime, - ) -> DomainResult> { - let at_str = at.to_rfc3339(); - let row: Option = sqlx::query_as( - "SELECT id, channel_id, valid_from, valid_until, generation \ - FROM generated_schedules \ - WHERE channel_id = $1 AND valid_from <= $2 AND valid_until > $3 \ - LIMIT 1", - ) - .bind(channel_id.value().to_string()) - .bind(&at_str) - .bind(&at_str) - .fetch_optional(&self.pool) - .await - .map_err(map_sqlx_error)?; - - match row { - None => Ok(None), - Some(r) => { - let slots = self.fetch_slots(&r.id).await?; - Some(map_schedule(r, slots)).transpose() - } - } - } - - async fn find_latest(&self, channel_id: ChannelId) -> DomainResult> { - let row: Option = sqlx::query_as( - "SELECT id, channel_id, valid_from, valid_until, generation \ - FROM generated_schedules \ - WHERE channel_id = $1 ORDER BY valid_from DESC LIMIT 1", - ) - .bind(channel_id.value().to_string()) - .fetch_optional(&self.pool) - .await - .map_err(map_sqlx_error)?; - - match row { - None => Ok(None), - Some(r) => { - let slots = self.fetch_slots(&r.id).await?; - Some(map_schedule(r, slots)).transpose() - } - } - } - - async fn find_playback_history( - &self, - channel_id: ChannelId, - ) -> DomainResult> { - let rows: Vec = sqlx::query_as( - "SELECT id, channel_id, item_id, played_at, generation \ - FROM playback_records WHERE channel_id = $1 ORDER BY played_at DESC", - ) - .bind(channel_id.value().to_string()) - .fetch_all(&self.pool) - .await - .map_err(map_sqlx_error)?; - - rows.into_iter().map(map_playback_row).collect() - } - - async fn find_last_slot_per_block( - &self, - channel_id: ChannelId, - ) -> DomainResult> { - let channel_id_str = channel_id.value().to_string(); - let rows: Vec = sqlx::query_as( - "SELECT ss.source_block_id, ss.item \ - FROM scheduled_slots ss \ - INNER JOIN generated_schedules gs ON gs.id = ss.schedule_id \ - WHERE gs.channel_id = $1 \ - AND ss.start_at = ( \ - SELECT MAX(ss2.start_at) \ - FROM scheduled_slots ss2 \ - INNER JOIN generated_schedules gs2 ON gs2.id = ss2.schedule_id \ - WHERE ss2.source_block_id = ss.source_block_id \ - AND gs2.channel_id = $2 \ - )", - ) - .bind(&channel_id_str) - .bind(&channel_id_str) - .fetch_all(&self.pool) - .await - .map_err(map_sqlx_error)?; - - let mut map = HashMap::new(); - for row in rows { - let block_id = - BlockId::from_uuid(parse_uuid(&row.source_block_id, "block id")?); - let item: MediaItem = parse_json(&row.item, "slot item")?; - map.insert(block_id, item.id().clone()); - } - Ok(map) - } - - async fn list_schedule_history( - &self, - channel_id: ChannelId, - ) -> DomainResult> { - let rows: Vec = sqlx::query_as( - "SELECT id, channel_id, valid_from, valid_until, generation \ - FROM generated_schedules WHERE channel_id = $1 ORDER BY generation DESC", - ) - .bind(channel_id.value().to_string()) - .fetch_all(&self.pool) - .await - .map_err(map_sqlx_error)?; - - rows.into_iter() - .map(|r| map_schedule(r, vec![])) - .collect() - } - - async fn get_schedule_by_id( - &self, - channel_id: ChannelId, - schedule_id: ScheduleId, - ) -> DomainResult> { - let row: Option = sqlx::query_as( - "SELECT id, channel_id, valid_from, valid_until, generation \ - FROM generated_schedules WHERE id = $1 AND channel_id = $2", - ) - .bind(schedule_id.value().to_string()) - .bind(channel_id.value().to_string()) - .fetch_optional(&self.pool) - .await - .map_err(map_sqlx_error)?; - - match row { - None => Ok(None), - Some(r) => { - let slots = self.fetch_slots(&r.id).await?; - Some(map_schedule(r, slots)).transpose() - } - } - } -} diff --git a/crates/adapters/postgres/src/settings.rs b/crates/adapters/postgres/src/settings.rs deleted file mode 100644 index e8b02d7..0000000 --- a/crates/adapters/postgres/src/settings.rs +++ /dev/null @@ -1,48 +0,0 @@ -use async_trait::async_trait; -use sqlx::PgPool; - -use domain::{ - ports::settings::AppSettingsRepository, - DomainError, DomainResult, -}; - -pub struct PgAppSettings { - pool: PgPool, -} - -impl PgAppSettings { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl AppSettingsRepository for PgAppSettings { - async fn get(&self, key: &str) -> DomainResult> { - sqlx::query_scalar::<_, String>("SELECT value FROM app_settings WHERE key = $1") - .bind(key) - .fetch_optional(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string())) - } - - async fn set(&self, key: &str, value: &str) -> DomainResult<()> { - sqlx::query( - "INSERT INTO app_settings (key, value) VALUES ($1, $2) - ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value", - ) - .bind(key) - .bind(value) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(|e| DomainError::InfrastructureError(e.to_string())) - } - - async fn get_all(&self) -> DomainResult> { - sqlx::query_as::<_, (String, String)>("SELECT key, value FROM app_settings ORDER BY key") - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string())) - } -} diff --git a/crates/adapters/postgres/src/transcode.rs b/crates/adapters/postgres/src/transcode.rs deleted file mode 100644 index 403ac0b..0000000 --- a/crates/adapters/postgres/src/transcode.rs +++ /dev/null @@ -1,38 +0,0 @@ -use async_trait::async_trait; -use sqlx::PgPool; - -use domain::{ - ports::transcode::TranscodeSettingsRepository, - DomainError, DomainResult, -}; - -pub struct PgTranscodeSettings { - pool: PgPool, -} - -impl PgTranscodeSettings { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl TranscodeSettingsRepository for PgTranscodeSettings { - async fn load_cleanup_ttl(&self) -> DomainResult> { - let row: Option<(i64,)> = - sqlx::query_as("SELECT cleanup_ttl_hours FROM transcode_settings WHERE id = 1") - .fetch_optional(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(row.map(|(h,)| h as u32)) - } - - async fn save_cleanup_ttl(&self, hours: u32) -> DomainResult<()> { - sqlx::query("UPDATE transcode_settings SET cleanup_ttl_hours = $1 WHERE id = 1") - .bind(hours as i64) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(()) - } -} diff --git a/crates/adapters/postgres/src/user.rs b/crates/adapters/postgres/src/user.rs deleted file mode 100644 index c4c7cee..0000000 --- a/crates/adapters/postgres/src/user.rs +++ /dev/null @@ -1,140 +0,0 @@ -use async_trait::async_trait; -use sqlx::PgPool; - -use adapter_common::{map_sqlx_error, parse_dt, parse_uuid}; -use domain::{ - ports::user::{UserCommand, UserQuery}, - DomainError, DomainResult, Email, User, UserId, -}; - -pub struct PgUserRepository { - pool: PgPool, -} - -impl PgUserRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[derive(Debug, sqlx::FromRow)] -struct UserRow { - id: String, - subject: String, - email: String, - password_hash: Option, - is_admin: bool, - created_at: String, -} - -impl UserRow { - fn into_user(self) -> DomainResult { - let id = UserId::from_uuid(parse_uuid(&self.id, "user id")?); - let email = Email::new(&self.email) - .map_err(|e| DomainError::RepositoryError(format!("Invalid email: {e}")))?; - let created_at = parse_dt(&self.created_at)?; - - Ok(User::from_persistence( - id, - self.subject, - email, - self.password_hash, - self.is_admin, - created_at, - )) - } -} - -#[async_trait] -impl UserCommand for PgUserRepository { - async fn save(&self, user: &User) -> DomainResult<()> { - let id = user.id().value().to_string(); - let created_at = user.created_at().to_rfc3339(); - - sqlx::query( - r#" - INSERT INTO users (id, subject, email, password_hash, is_admin, created_at) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT(id) DO UPDATE SET - subject = EXCLUDED.subject, - email = EXCLUDED.email, - password_hash = EXCLUDED.password_hash, - is_admin = EXCLUDED.is_admin - "#, - ) - .bind(&id) - .bind(user.subject()) - .bind(user.email().as_ref()) - .bind(user.password_hash()) - .bind(user.is_admin()) - .bind(&created_at) - .execute(&self.pool) - .await - .map_err(|e| { - let msg = e.to_string(); - if msg.contains("unique constraint") || msg.contains("duplicate key") { - DomainError::UserAlreadyExists(user.email().as_ref().to_string()) - } else { - map_sqlx_error(e) - } - })?; - - Ok(()) - } - - async fn delete(&self, id: UserId) -> DomainResult<()> { - sqlx::query("DELETE FROM users WHERE id = $1") - .bind(id.value().to_string()) - .execute(&self.pool) - .await - .map_err(map_sqlx_error)?; - Ok(()) - } -} - -#[async_trait] -impl UserQuery for PgUserRepository { - async fn find_by_id(&self, id: UserId) -> DomainResult> { - let row: Option = sqlx::query_as( - "SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE id = $1", - ) - .bind(id.value().to_string()) - .fetch_optional(&self.pool) - .await - .map_err(map_sqlx_error)?; - - row.map(UserRow::into_user).transpose() - } - - async fn find_by_subject(&self, subject: &str) -> DomainResult> { - let row: Option = sqlx::query_as( - "SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE subject = $1", - ) - .bind(subject) - .fetch_optional(&self.pool) - .await - .map_err(map_sqlx_error)?; - - row.map(UserRow::into_user).transpose() - } - - async fn find_by_email(&self, email: &str) -> DomainResult> { - let row: Option = sqlx::query_as( - "SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE email = $1", - ) - .bind(email) - .fetch_optional(&self.pool) - .await - .map_err(map_sqlx_error)?; - - row.map(UserRow::into_user).transpose() - } - - async fn count_users(&self) -> DomainResult { - let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") - .fetch_one(&self.pool) - .await - .map_err(map_sqlx_error)?; - Ok(count as u64) - } -} diff --git a/crates/adapters/postgres/src/wire.rs b/crates/adapters/postgres/src/wire.rs deleted file mode 100644 index bc22657..0000000 --- a/crates/adapters/postgres/src/wire.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::sync::Arc; - -use sqlx::PgPool; - -use domain::ports::{ - activity::{ActivityLogCommand, ActivityLogQuery}, - channel::{ChannelCommand, ChannelQuery}, - library::{LibraryCommand, LibraryQuery}, - provider_config::{ProviderConfigCommand, ProviderConfigQuery}, - schedule::{ScheduleCommand, ScheduleQuery}, - settings::AppSettingsRepository, - transcode::TranscodeSettingsRepository, - user::{UserCommand, UserQuery}, -}; - -use crate::{ - activity::PgActivityLog, - channel::PgChannelRepository, - library::PgLibraryRepository, - provider_config::PgProviderConfig, - schedule::PgScheduleRepository, - settings::PgAppSettings, - transcode::PgTranscodeSettings, - user::PgUserRepository, -}; - -pub struct PostgresWireOutput { - pub user_command: Arc, - pub user_query: Arc, - pub channel_command: Arc, - pub channel_query: Arc, - pub schedule_command: Arc, - pub schedule_query: Arc, - pub library_command: Arc, - pub library_query: Arc, - pub activity_command: Arc, - pub activity_query: Arc, - pub settings: Arc, - pub provider_config_command: Arc, - pub provider_config_query: Arc, - pub transcode_settings: Arc, -} - -pub fn wire(pool: PgPool) -> PostgresWireOutput { - let user = Arc::new(PgUserRepository::new(pool.clone())); - let channel = Arc::new(PgChannelRepository::new(pool.clone())); - let schedule = Arc::new(PgScheduleRepository::new(pool.clone())); - let library = Arc::new(PgLibraryRepository::new(pool.clone())); - let activity = Arc::new(PgActivityLog::new(pool.clone())); - let settings = Arc::new(PgAppSettings::new(pool.clone())); - let provider_config = Arc::new(PgProviderConfig::new(pool.clone())); - let transcode_settings = Arc::new(PgTranscodeSettings::new(pool)); - - PostgresWireOutput { - user_command: user.clone(), - user_query: user, - channel_command: channel.clone(), - channel_query: channel, - schedule_command: schedule.clone(), - schedule_query: schedule, - library_command: library.clone(), - library_query: library, - activity_command: activity.clone(), - activity_query: activity, - settings, - provider_config_command: provider_config.clone(), - provider_config_query: provider_config, - transcode_settings, - } -} diff --git a/crates/api-types/src/admin.rs b/crates/api-types/src/admin.rs index 2385ce3..f036908 100644 --- a/crates/api-types/src/admin.rs +++ b/crates/api-types/src/admin.rs @@ -17,6 +17,11 @@ pub struct ActivityEventResponse { pub channel_id: Option, } +#[derive(Debug, Deserialize, ToSchema)] +pub struct ActivityLogParams { + pub limit: Option, +} + impl From for ActivityEventResponse { fn from(e: domain::ActivityEvent) -> Self { Self { diff --git a/crates/api-types/src/iptv.rs b/crates/api-types/src/iptv.rs new file mode 100644 index 0000000..b5a45ea --- /dev/null +++ b/crates/api-types/src/iptv.rs @@ -0,0 +1,7 @@ +use serde::Deserialize; +use utoipa::ToSchema; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct IptvParams { + pub token: Option, +} diff --git a/crates/api-types/src/lib.rs b/crates/api-types/src/lib.rs index 0b03f9a..f74bd8a 100644 --- a/crates/api-types/src/lib.rs +++ b/crates/api-types/src/lib.rs @@ -3,12 +3,13 @@ pub mod auth; pub mod channels; pub mod common; pub mod config; +pub mod iptv; pub mod library; pub mod providers; pub mod schedule; pub mod transcode; -pub use admin::{ActivityEventResponse, SettingsResponse}; +pub use admin::{ActivityEventResponse, ActivityLogParams, SettingsResponse}; pub use auth::{LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserResponse}; pub use channels::{ ChannelResponse, ConfigSnapshotResponse, CreateChannelRequest, PatchSnapshotRequest, @@ -16,7 +17,11 @@ pub use channels::{ }; pub use common::{ErrorResponse, PaginatedResponse}; pub use config::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo}; -pub use library::{CollectionResponse, LibraryItemResponse, SeasonResponse, ShowResponse}; +pub use iptv::IptvParams; +pub use library::{ + CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, ProviderParam, + SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, +}; pub use providers::{ProviderConfigRequest, ProviderConfigResponse}; pub use schedule::{ CurrentBroadcastResponse, MediaItemResponse, ScheduleHistoryEntry, ScheduleResponse, diff --git a/crates/api-types/src/library.rs b/crates/api-types/src/library.rs index 03bdb9b..dc54fc3 100644 --- a/crates/api-types/src/library.rs +++ b/crates/api-types/src/library.rs @@ -102,3 +102,44 @@ impl From for SeasonResponse { } } } + +#[derive(Debug, Deserialize, ToSchema)] +pub struct LibrarySearchParams { + pub provider: Option, + pub content_type: Option, + #[serde(default, rename = "genres[]")] + pub genres: Vec, + pub search_term: Option, + pub collection_id: Option, + #[serde(default, rename = "series_names[]")] + pub series_names: Vec, + pub season_number: Option, + pub decade: Option, + pub offset: Option, + pub limit: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct ProviderParam { + pub provider: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct ShowsParams { + pub provider: Option, + pub search_term: Option, + #[serde(default, rename = "genres[]")] + pub genres: Vec, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct SeasonsParams { + pub series_name: String, + pub provider: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct GenresParams { + pub content_type: Option, + pub provider: Option, +} diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index 83aa8cc..cfa9d7c 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -15,7 +15,6 @@ rand = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } -url = { workspace = true } uuid = { workspace = true } [dev-dependencies] diff --git a/crates/domain/src/errors/mod.rs b/crates/domain/src/errors/mod.rs index 84fb3d4..4c6de58 100644 --- a/crates/domain/src/errors/mod.rs +++ b/crates/domain/src/errors/mod.rs @@ -29,6 +29,12 @@ pub enum DomainError { #[error("Forbidden: {0}")] Forbidden(String), + #[error("Not found: {0}")] + NotFound(String), + + #[error("Conflict: {0}")] + Conflict(String), + #[error("Repository error: {0}")] RepositoryError(String), @@ -52,12 +58,12 @@ impl DomainError { pub fn is_not_found(&self) -> bool { matches!( self, - DomainError::UserNotFound(_) | DomainError::ChannelNotFound(_) + DomainError::UserNotFound(_) | DomainError::ChannelNotFound(_) | DomainError::NotFound(_) ) } pub fn is_conflict(&self) -> bool { - matches!(self, DomainError::UserAlreadyExists(_)) + matches!(self, DomainError::UserAlreadyExists(_) | DomainError::Conflict(_)) } } diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs index b5c5341..cb88edc 100644 --- a/crates/domain/src/value_objects/mod.rs +++ b/crates/domain/src/value_objects/mod.rs @@ -1,13 +1,11 @@ pub mod auth; pub mod channel; pub mod ids; -pub mod oidc; pub mod scheduling; pub mod search; pub use auth::*; pub use channel::*; pub use ids::*; -pub use oidc::*; pub use scheduling::*; pub use search::*; diff --git a/crates/domain/src/value_objects/oidc.rs b/crates/domain/src/value_objects/oidc.rs deleted file mode 100644 index b1f90b1..0000000 --- a/crates/domain/src/value_objects/oidc.rs +++ /dev/null @@ -1,345 +0,0 @@ -use serde::{Deserialize, Deserializer, Serialize}; -use std::fmt; -use url::Url; - -use super::auth::ValidationError; - -// Stores original string to preserve exact formatting — OIDC providers expect issuer URLs to match exactly -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(try_from = "String", into = "String")] -pub struct IssuerUrl(String); - -impl IssuerUrl { - pub fn new(value: impl AsRef) -> Result { - let value = value.as_ref().trim().to_string(); - Url::parse(&value).map_err(|e| ValidationError::InvalidUrl(e.to_string()))?; - Ok(Self(value)) - } -} - -impl AsRef for IssuerUrl { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for IssuerUrl { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl TryFrom for IssuerUrl { - type Error = ValidationError; - fn try_from(value: String) -> Result { - Self::new(value) - } -} - -impl From for String { - fn from(val: IssuerUrl) -> Self { - val.0 - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(try_from = "String", into = "String")] -pub struct ClientId(String); - -impl ClientId { - pub fn new(value: impl Into) -> Result { - let value = value.into().trim().to_string(); - if value.is_empty() { - return Err(ValidationError::Empty("client_id".to_string())); - } - Ok(Self(value)) - } -} - -impl AsRef for ClientId { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for ClientId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl TryFrom for ClientId { - type Error = ValidationError; - fn try_from(value: String) -> Result { - Self::new(value) - } -} - -impl From for String { - fn from(val: ClientId) -> Self { - val.0 - } -} - -// Hidden in Debug for security -#[derive(Clone, PartialEq, Eq)] -pub struct ClientSecret(String); - -impl ClientSecret { - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } - - pub fn is_empty(&self) -> bool { - self.0.trim().is_empty() - } -} - -impl AsRef for ClientSecret { - fn as_ref(&self) -> &str { - &self.0 - } -} - -// Hidden in Debug for security -impl fmt::Debug for ClientSecret { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "ClientSecret(***)") - } -} - -impl fmt::Display for ClientSecret { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "***") - } -} - -impl<'de> Deserialize<'de> for ClientSecret { - fn deserialize>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - Ok(Self::new(s)) - } -} - -// ClientSecret must NOT implement Serialize - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(try_from = "String", into = "String")] -pub struct RedirectUrl(Url); - -impl RedirectUrl { - pub fn new(value: impl AsRef) -> Result { - let value = value.as_ref().trim(); - let url = Url::parse(value).map_err(|e| ValidationError::InvalidUrl(e.to_string()))?; - Ok(Self(url)) - } - - pub fn as_url(&self) -> &Url { - &self.0 - } -} - -impl AsRef for RedirectUrl { - fn as_ref(&self) -> &str { - self.0.as_str() - } -} - -impl fmt::Display for RedirectUrl { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl TryFrom for RedirectUrl { - type Error = ValidationError; - fn try_from(value: String) -> Result { - Self::new(value) - } -} - -impl From for String { - fn from(val: RedirectUrl) -> Self { - val.0.to_string() - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(try_from = "String", into = "String")] -pub struct ResourceId(String); - -impl ResourceId { - pub fn new(value: impl Into) -> Result { - let value = value.into().trim().to_string(); - if value.is_empty() { - return Err(ValidationError::Empty("resource_id".to_string())); - } - Ok(Self(value)) - } -} - -impl AsRef for ResourceId { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for ResourceId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl TryFrom for ResourceId { - type Error = ValidationError; - fn try_from(value: String) -> Result { - Self::new(value) - } -} - -impl From for String { - fn from(val: ResourceId) -> Self { - val.0 - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CsrfToken(String); - -impl CsrfToken { - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } -} - -impl AsRef for CsrfToken { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for CsrfToken { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct OidcNonce(String); - -impl OidcNonce { - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } -} - -impl AsRef for OidcNonce { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for OidcNonce { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -// Hidden in Debug for security -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PkceVerifier(String); - -impl PkceVerifier { - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } -} - -impl AsRef for PkceVerifier { - fn as_ref(&self) -> &str { - &self.0 - } -} - -// Hidden in Debug for security -impl fmt::Debug for PkceVerifier { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "PkceVerifier(***)") - } -} - -// Hidden in Debug for security -#[derive(Clone, PartialEq, Eq)] -pub struct AuthorizationCode(String); - -impl AuthorizationCode { - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } -} - -impl AsRef for AuthorizationCode { - fn as_ref(&self) -> &str { - &self.0 - } -} - -// Hidden in Debug for security -impl fmt::Debug for AuthorizationCode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "AuthorizationCode(***)") - } -} - -impl<'de> Deserialize<'de> for AuthorizationCode { - fn deserialize>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - Ok(Self::new(s)) - } -} - -#[derive(Debug, Clone)] -pub struct AuthorizationUrlData { - pub url: Url, - pub csrf_token: CsrfToken, - pub nonce: OidcNonce, - pub pkce_verifier: PkceVerifier, -} - -pub const MIN_JWT_SECRET_LENGTH: usize = 32; - -#[derive(Clone, PartialEq, Eq)] -pub struct JwtSecret(String); - -impl JwtSecret { - pub fn new(value: impl Into, is_production: bool) -> Result { - let value = value.into(); - if is_production && value.len() < MIN_JWT_SECRET_LENGTH { - return Err(ValidationError::SecretTooShort { - min: MIN_JWT_SECRET_LENGTH, - actual: value.len(), - }); - } - Ok(Self(value)) - } - - pub fn new_unchecked(value: impl Into) -> Self { - Self(value.into()) - } -} - -impl AsRef for JwtSecret { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl fmt::Debug for JwtSecret { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "JwtSecret(***)") - } -} - -#[cfg(test)] -#[path = "tests/oidc.rs"] -mod tests; diff --git a/crates/domain/src/value_objects/tests/oidc.rs b/crates/domain/src/value_objects/tests/oidc.rs deleted file mode 100644 index 35d5022..0000000 --- a/crates/domain/src/value_objects/tests/oidc.rs +++ /dev/null @@ -1,51 +0,0 @@ -use super::*; - -mod oidc_tests { - use super::*; - - #[test] - fn test_issuer_url_valid() { - assert!(IssuerUrl::new("https://auth.example.com").is_ok()); - } - - #[test] - fn test_issuer_url_invalid() { - assert!(IssuerUrl::new("not-a-url").is_err()); - } - - #[test] - fn test_client_id_non_empty() { - assert!(ClientId::new("my-client").is_ok()); - assert!(ClientId::new("").is_err()); - assert!(ClientId::new(" ").is_err()); - } - - #[test] - fn test_client_secret_hides_in_debug() { - let secret = ClientSecret::new("super-secret"); - let debug = format!("{:?}", secret); - assert!(!debug.contains("super-secret")); - assert!(debug.contains("***")); - } -} - -mod secret_tests { - use super::*; - - #[test] - fn test_jwt_secret_production_check() { - let short = "short"; - let long = "a".repeat(32); - - assert!(JwtSecret::new(short, true).is_err()); - assert!(JwtSecret::new(&long, true).is_ok()); - - assert!(JwtSecret::new(short, false).is_ok()); - } - - #[test] - fn test_secrets_hide_in_debug() { - let jwt = JwtSecret::new_unchecked("secret"); - assert!(!format!("{:?}", jwt).contains("secret")); - } -} diff --git a/crates/infra-wiring/Cargo.toml b/crates/infra-wiring/Cargo.toml index 0c66571..5ed10ed 100644 --- a/crates/infra-wiring/Cargo.toml +++ b/crates/infra-wiring/Cargo.toml @@ -6,7 +6,6 @@ edition = "2024" [features] default = ["sqlite"] sqlite = ["sqlx/sqlite"] -postgres = ["sqlx/postgres"] [dependencies] sqlx = { workspace = true } diff --git a/crates/infra-wiring/src/lib.rs b/crates/infra-wiring/src/lib.rs index 8e3d57a..d05cdd4 100644 --- a/crates/infra-wiring/src/lib.rs +++ b/crates/infra-wiring/src/lib.rs @@ -41,9 +41,6 @@ pub enum DbError { pub enum DbPool { #[cfg(feature = "sqlite")] Sqlite(sqlx::SqlitePool), - - #[cfg(feature = "postgres")] - Postgres(sqlx::PgPool), } impl DbPool { @@ -60,11 +57,6 @@ impl DbPool { let pool = sqlx::SqlitePool::connect(database_url).await?; Ok(Self::Sqlite(pool)) } - #[cfg(feature = "postgres")] - "postgres" | "postgresql" => { - let pool = sqlx::PgPool::connect(database_url).await?; - Ok(Self::Postgres(pool)) - } other => Err(DbError::UnsupportedScheme(other.to_string())), } } @@ -77,10 +69,6 @@ impl DbPool { .run(pool) .await?; } - #[cfg(feature = "postgres")] - Self::Postgres(_pool) => { - tracing::warn!("postgres migrations not yet available"); - } } Ok(()) } @@ -112,11 +100,6 @@ pub struct Config { pub jwt_expiry_hours: u64, pub jwt_refresh_expiry_days: u64, pub allow_registration: bool, - pub oidc_issuer_url: Option, - pub oidc_client_id: Option, - pub oidc_client_secret: Option, - pub oidc_redirect_url: Option, - pub oidc_resource_id: Option, pub jellyfin_url: Option, pub jellyfin_api_key: Option, pub jellyfin_user_id: Option, @@ -178,12 +161,6 @@ impl Config { .and_then(|s| s.parse().ok()) .unwrap_or(DEFAULT_JWT_REFRESH_EXPIRY_DAYS); - let oidc_issuer_url = env::var("OIDC_ISSUER").ok(); - let oidc_client_id = env::var("OIDC_CLIENT_ID").ok(); - let oidc_client_secret = env::var("OIDC_CLIENT_SECRET").ok(); - let oidc_redirect_url = env::var("OIDC_REDIRECT_URL").ok(); - let oidc_resource_id = env::var("OIDC_RESOURCE_ID").ok(); - let is_production = env::var("PRODUCTION") .or_else(|_| env::var("RUST_ENV")) .map(|v| { @@ -234,11 +211,6 @@ impl Config { jwt_expiry_hours, jwt_refresh_expiry_days, allow_registration, - oidc_issuer_url, - oidc_client_id, - oidc_client_secret, - oidc_redirect_url, - oidc_resource_id, jellyfin_url, jellyfin_api_key, jellyfin_user_id, diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 0eb0357..5c91835 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -10,7 +10,6 @@ path = "src/main.rs" [features] default = ["sqlite", "jellyfin"] sqlite = ["dep:adapter-sqlite", "infra-wiring/sqlite"] -postgres = ["dep:adapter-postgres", "infra-wiring/postgres"] jellyfin = ["dep:adapter-jellyfin"] [dependencies] @@ -21,7 +20,6 @@ adapter-auth = { workspace = true } adapter-event-publisher = { workspace = true } adapter-sqlite = { workspace = true, optional = true } -adapter-postgres = { workspace = true, optional = true } adapter-jellyfin = { workspace = true, optional = true } rmcp = { version = "0.1", features = ["server", "transport-io"] } diff --git a/crates/mcp/src/main.rs b/crates/mcp/src/main.rs index 54151cb..5a0940c 100644 --- a/crates/mcp/src/main.rs +++ b/crates/mcp/src/main.rs @@ -118,18 +118,6 @@ fn wire_repositories(pool: &DbPool) -> anyhow::Result { library_query: w.library_query, }) } - #[cfg(feature = "postgres")] - DbPool::Postgres(pg_pool) => { - let w = adapter_postgres::wire(pg_pool.clone()); - Ok(WireOutput { - channel_command: w.channel_command, - channel_query: w.channel_query, - schedule_command: w.schedule_command, - schedule_query: w.schedule_query, - library_query: w.library_query, - }) - } - _ => anyhow::bail!("database backend not compiled into this binary"), } } diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index 1b59f7c..a8caefa 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -10,9 +10,7 @@ path = "src/main.rs" [features] default = ["sqlite", "auth-jwt", "jellyfin"] sqlite = ["dep:adapter-sqlite", "infra-wiring/sqlite"] -postgres = ["dep:adapter-postgres", "infra-wiring/postgres"] auth-jwt = ["adapter-auth/jwt"] -auth-oidc = ["adapter-auth/oidc"] jellyfin = ["dep:adapter-jellyfin"] local-files = ["dep:adapter-local-files", "dep:tokio-util"] @@ -26,7 +24,6 @@ adapter-event-publisher = { workspace = true } # Feature-gated adapters adapter-sqlite = { workspace = true, optional = true } -adapter-postgres = { workspace = true, optional = true } adapter-jellyfin = { workspace = true, optional = true } adapter-local-files = { workspace = true, optional = true } diff --git a/crates/presentation/src/errors.rs b/crates/presentation/src/errors.rs index 46a97f7..20bd463 100644 --- a/crates/presentation/src/errors.rs +++ b/crates/presentation/src/errors.rs @@ -1,144 +1,47 @@ -use axum::{ - Json, - http::StatusCode, - response::{IntoResponse, Response}, -}; -use serde::Serialize; -use thiserror::Error; - +use axum::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; use domain::DomainError; -#[derive(Debug, Error)] -pub enum ApiError { - #[error("{0}")] - Domain(#[from] DomainError), +pub struct AppError(pub DomainError); - #[error("Validation error: {0}")] - Validation(String), - - #[error("Internal server error")] - Internal(String), - - #[error("Forbidden: {0}")] - Forbidden(String), - - #[error("Unauthorized: {0}")] - Unauthorized(String), - - #[error("Not found: {0}")] - NotFound(String), - - #[error("Conflict: {0}")] - Conflict(String), -} - -#[derive(Debug, Serialize)] -pub struct ErrorResponse { - pub error: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub details: Option, -} - -impl IntoResponse for ApiError { +impl IntoResponse for AppError { fn into_response(self) -> Response { - let (status, error_response) = match &self { - ApiError::Domain(domain_error) => { - let status = match domain_error { - DomainError::UserNotFound(_) - | DomainError::ChannelNotFound(_) - | DomainError::NoActiveSchedule(_) => StatusCode::NOT_FOUND, + let status = match &self.0 { + DomainError::UserNotFound(_) + | DomainError::ChannelNotFound(_) + | DomainError::NoActiveSchedule(_) + | DomainError::NotFound(_) => StatusCode::NOT_FOUND, - DomainError::UserAlreadyExists(_) => StatusCode::CONFLICT, + DomainError::UserAlreadyExists(_) | DomainError::Conflict(_) => StatusCode::CONFLICT, - DomainError::ValidationError(_) | DomainError::TimezoneError(_) => { - StatusCode::BAD_REQUEST - } - - DomainError::Unauthenticated(_) => StatusCode::UNAUTHORIZED, - DomainError::Forbidden(_) => StatusCode::FORBIDDEN, - - DomainError::RepositoryError(_) | DomainError::InfrastructureError(_) => { - StatusCode::INTERNAL_SERVER_ERROR - } - - _ => StatusCode::INTERNAL_SERVER_ERROR, - }; - - ( - status, - ErrorResponse { - error: domain_error.to_string(), - details: None, - }, - ) + DomainError::ValidationError(_) | DomainError::TimezoneError(_) => { + StatusCode::BAD_REQUEST } - ApiError::Validation(msg) => ( - StatusCode::BAD_REQUEST, - ErrorResponse { - error: "Validation error".to_string(), - details: Some(msg.clone()), - }, - ), + DomainError::Unauthenticated(_) => StatusCode::UNAUTHORIZED, + DomainError::Forbidden(_) => StatusCode::FORBIDDEN, - ApiError::Internal(msg) => { - tracing::error!("Internal error: {}", msg); - ( - StatusCode::INTERNAL_SERVER_ERROR, - ErrorResponse { - error: "Internal server error".to_string(), - details: None, - }, - ) + DomainError::RepositoryError(_) | DomainError::InfrastructureError(_) => { + StatusCode::INTERNAL_SERVER_ERROR } - ApiError::Forbidden(msg) => ( - StatusCode::FORBIDDEN, - ErrorResponse { - error: "Forbidden".to_string(), - details: Some(msg.clone()), - }, - ), - - ApiError::Unauthorized(msg) => ( - StatusCode::UNAUTHORIZED, - ErrorResponse { - error: "Unauthorized".to_string(), - details: Some(msg.clone()), - }, - ), - - ApiError::NotFound(msg) => ( - StatusCode::NOT_FOUND, - ErrorResponse { - error: "Not found".to_string(), - details: Some(msg.clone()), - }, - ), - - ApiError::Conflict(msg) => ( - StatusCode::CONFLICT, - ErrorResponse { - error: "Conflict".to_string(), - details: Some(msg.clone()), - }, - ), + _ => StatusCode::INTERNAL_SERVER_ERROR, }; - (status, Json(error_response)).into_response() + let body = api_types::ErrorResponse::new(self.0.to_string()); + (status, Json(body)).into_response() } } -impl ApiError { - pub fn validation(msg: impl Into) -> Self { - Self::Validation(msg.into()) - } - - pub fn not_found(msg: impl Into) -> Self { - Self::NotFound(msg.into()) - } - - pub fn conflict(msg: impl Into) -> Self { - Self::Conflict(msg.into()) +impl From for AppError { + fn from(e: DomainError) -> Self { + Self(e) + } +} + +impl From for AppError { + fn from(e: serde_json::Error) -> Self { + Self(DomainError::ValidationError(e.to_string())) } } diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index 31f6644..b01e717 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -1,14 +1,14 @@ use axum::extract::FromRequestParts; use axum::http::request::Parts; -use domain::User; +use domain::{DomainError, User}; -use crate::errors::ApiError; +use crate::errors::AppError; use crate::state::AppState; pub struct CurrentUser(pub User); impl FromRequestParts for CurrentUser { - type Rejection = ApiError; + type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, @@ -25,9 +25,9 @@ impl FromRequestParts for CurrentUser { #[cfg(not(feature = "auth-jwt"))] { let _ = (parts, state); - Err(ApiError::Unauthorized( + Err(AppError(DomainError::Unauthenticated( "No authentication backend configured".to_string(), - )) + ))) } } } @@ -35,7 +35,7 @@ impl FromRequestParts for CurrentUser { pub struct OptionalCurrentUser(pub Option); impl FromRequestParts for OptionalCurrentUser { - type Rejection = ApiError; + type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, @@ -69,7 +69,7 @@ impl FromRequestParts for OptionalCurrentUser { pub struct AdminUser(pub User); impl FromRequestParts for AdminUser { - type Rejection = ApiError; + type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, @@ -77,64 +77,64 @@ impl FromRequestParts for AdminUser { ) -> Result { let CurrentUser(user) = CurrentUser::from_request_parts(parts, state).await?; if !user.is_admin() { - return Err(ApiError::Forbidden("Admin access required".to_string())); + return Err(AppError(DomainError::Forbidden("Admin access required".to_string()))); } Ok(AdminUser(user)) } } #[cfg(feature = "auth-jwt")] -async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result { +async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result { use axum::http::header::AUTHORIZATION; let auth_header = parts .headers .get(AUTHORIZATION) - .ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?; + .ok_or_else(|| AppError(DomainError::Unauthenticated("Missing Authorization header".to_string())))?; let auth_str = auth_header .to_str() - .map_err(|_| ApiError::Unauthorized("Invalid Authorization header encoding".to_string()))?; + .map_err(|_| AppError(DomainError::Unauthenticated("Invalid Authorization header encoding".to_string())))?; let token = auth_str.strip_prefix("Bearer ").ok_or_else(|| { - ApiError::Unauthorized("Authorization header must use Bearer scheme".to_string()) + AppError(DomainError::Unauthenticated("Authorization header must use Bearer scheme".to_string())) })?; validate_jwt_token(token, state).await } #[cfg(feature = "auth-jwt")] -pub(crate) async fn validate_jwt_token(token: &str, state: &AppState) -> Result { +pub(crate) async fn validate_jwt_token(token: &str, state: &AppState) -> Result { let validator = state .jwt_validator .as_ref() - .ok_or_else(|| ApiError::Internal("JWT validator not configured".to_string()))?; + .ok_or_else(|| AppError(DomainError::InfrastructureError("JWT validator not configured".to_string())))?; let claims = validator.validate_access_token(token).map_err(|e| { tracing::debug!("JWT validation failed: {:?}", e); match e { adapter_auth::JwtError::Expired => { - ApiError::Unauthorized("Token expired".to_string()) + AppError(DomainError::Unauthenticated("Token expired".to_string())) } adapter_auth::JwtError::InvalidFormat => { - ApiError::Unauthorized("Invalid token format".to_string()) + AppError(DomainError::Unauthenticated("Invalid token format".to_string())) } - _ => ApiError::Unauthorized("Token validation failed".to_string()), + _ => AppError(DomainError::Unauthenticated("Token validation failed".to_string())), } })?; let user_id: uuid::Uuid = claims .sub .parse() - .map_err(|_| ApiError::Unauthorized("Invalid user ID in token".to_string()))?; + .map_err(|_| AppError(DomainError::Unauthenticated("Invalid user ID in token".to_string())))?; let user = state .auth_deps .user_query .find_by_id(domain::UserId::from(user_id)) .await - .map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))? - .ok_or_else(|| ApiError::Unauthorized("User not found".to_string()))?; + .map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to fetch user: {}", e))))? + .ok_or_else(|| AppError(DomainError::Unauthenticated("User not found".to_string())))?; Ok(user) } diff --git a/crates/presentation/src/factory.rs b/crates/presentation/src/factory.rs index 9bd1db5..4bc3044 100644 --- a/crates/presentation/src/factory.rs +++ b/crates/presentation/src/factory.rs @@ -192,25 +192,6 @@ fn wire_repositories(pool: &DbPool) -> anyhow::Result { provider_config_query: w.provider_config_query, }) } - #[cfg(feature = "postgres")] - DbPool::Postgres(pg_pool) => { - let w = adapter_postgres::wire(pg_pool.clone()); - Ok(WireOutput { - user_command: w.user_command, - user_query: w.user_query, - channel_command: w.channel_command, - channel_query: w.channel_query, - schedule_command: w.schedule_command, - schedule_query: w.schedule_query, - library_command: w.library_command, - library_query: w.library_query, - activity_query: w.activity_query, - settings: w.settings, - provider_config_command: w.provider_config_command, - provider_config_query: w.provider_config_query, - }) - } - _ => anyhow::bail!("database backend not compiled into this binary"), } } diff --git a/crates/presentation/src/handlers/admin.rs b/crates/presentation/src/handlers/admin.rs index 5f2d0f3..b231bfb 100644 --- a/crates/presentation/src/handlers/admin.rs +++ b/crates/presentation/src/handlers/admin.rs @@ -1,12 +1,11 @@ use axum::Json; use axum::extract::{Query, State}; -use serde::Deserialize; use std::collections::HashMap; -use api_types::{ActivityEventResponse, SettingsResponse}; +use api_types::{ActivityEventResponse, ActivityLogParams, SettingsResponse}; use application::admin::{GetActivityLogQuery, GetSettingsQuery, UpdateSettingsCommand}; -use crate::errors::ApiError; +use crate::errors::AppError; use crate::extractors::AdminUser; use crate::state::AppState; @@ -15,7 +14,7 @@ const DEFAULT_ACTIVITY_LIMIT: u32 = 50; pub async fn get_settings( State(state): State, AdminUser(_user): AdminUser, -) -> Result, ApiError> { +) -> Result, AppError> { let pairs = application::admin::get_settings::execute(&state.admin_deps, GetSettingsQuery).await?; let settings: HashMap = pairs.into_iter().collect(); @@ -26,7 +25,7 @@ pub async fn update_settings( State(state): State, AdminUser(_user): AdminUser, Json(body): Json>, -) -> Result, ApiError> { +) -> Result, AppError> { let settings_vec: Vec<(String, String)> = body.into_iter().collect(); let cmd = UpdateSettingsCommand { settings: settings_vec, @@ -39,16 +38,11 @@ pub async fn update_settings( Ok(Json(SettingsResponse { settings })) } -#[derive(Debug, Deserialize)] -pub struct ActivityLogParams { - pub limit: Option, -} - pub async fn get_activity_log( State(state): State, AdminUser(_user): AdminUser, Query(params): Query, -) -> Result>, ApiError> { +) -> Result>, AppError> { let query = GetActivityLogQuery { limit: params.limit.unwrap_or(DEFAULT_ACTIVITY_LIMIT), }; diff --git a/crates/presentation/src/handlers/auth.rs b/crates/presentation/src/handlers/auth.rs index bf13dab..2736b89 100644 --- a/crates/presentation/src/handlers/auth.rs +++ b/crates/presentation/src/handlers/auth.rs @@ -3,8 +3,9 @@ use axum::extract::State; use api_types::{LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserResponse}; use application::auth::{LoginCommand, RegisterCommand}; +use domain::DomainError; -use crate::errors::ApiError; +use crate::errors::AppError; use crate::extractors::CurrentUser; use crate::state::AppState; @@ -14,7 +15,7 @@ const SECS_PER_HOUR: u64 = 3600; pub async fn register( State(state): State, Json(req): Json, -) -> Result, ApiError> { +) -> Result, AppError> { let cmd = RegisterCommand { email: req.email, password: req.password, @@ -26,7 +27,7 @@ pub async fn register( pub async fn login( State(state): State, Json(req): Json, -) -> Result, ApiError> { +) -> Result, AppError> { let cmd = LoginCommand { email: req.email, password: req.password, @@ -41,13 +42,13 @@ pub async fn login( })) } -pub async fn logout() -> Result, ApiError> { +pub async fn logout() -> Result, AppError> { Ok(Json(serde_json::json!({"message": "logged out"}))) } pub async fn me( CurrentUser(user): CurrentUser, -) -> Result, ApiError> { +) -> Result, AppError> { Ok(Json(UserResponse::from(user))) } @@ -55,7 +56,7 @@ pub async fn me( pub async fn get_token( State(state): State, Json(req): Json, -) -> Result, ApiError> { +) -> Result, AppError> { let cmd = LoginCommand { email: req.email, password: req.password, @@ -74,29 +75,29 @@ pub async fn get_token( pub async fn refresh_token( State(state): State, Json(req): Json, -) -> Result, ApiError> { +) -> Result, AppError> { let validator = state .jwt_validator .as_ref() - .ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?; + .ok_or_else(|| AppError(DomainError::InfrastructureError("JWT not configured".to_string())))?; let claims = validator.validate_refresh_token(&req.refresh_token).map_err(|e| { tracing::debug!("Refresh token validation failed: {:?}", e); - ApiError::Unauthorized("Invalid refresh token".to_string()) + AppError(DomainError::Unauthenticated("Invalid refresh token".to_string())) })?; let user_id: uuid::Uuid = claims .sub .parse() - .map_err(|_| ApiError::Unauthorized("Invalid user ID in token".to_string()))?; + .map_err(|_| AppError(DomainError::Unauthenticated("Invalid user ID in token".to_string())))?; let user = state .auth_deps .user_query .find_by_id(domain::UserId::from(user_id)) .await - .map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))? - .ok_or_else(|| ApiError::Unauthorized("User not found".to_string()))?; + .map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to fetch user: {}", e))))? + .ok_or_else(|| AppError(DomainError::Unauthenticated("User not found".to_string())))?; let (access_token, refresh_token) = create_tokens(&user, &state, true)?; Ok(Json(TokenResponse { @@ -111,23 +112,23 @@ fn create_tokens( user: &domain::User, state: &AppState, remember_me: bool, -) -> Result<(String, Option), ApiError> { +) -> Result<(String, Option), AppError> { #[cfg(feature = "auth-jwt")] { let validator = state .jwt_validator .as_ref() - .ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?; + .ok_or_else(|| AppError(DomainError::InfrastructureError("JWT not configured".to_string())))?; let access = validator .create_token(user) - .map_err(|e| ApiError::Internal(format!("Failed to create token: {}", e)))?; + .map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to create token: {}", e))))?; let refresh = if remember_me { Some( validator .create_refresh_token(user) - .map_err(|e| ApiError::Internal(format!("Failed to create refresh token: {}", e)))?, + .map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to create refresh token: {}", e))))?, ) } else { None @@ -139,6 +140,6 @@ fn create_tokens( #[cfg(not(feature = "auth-jwt"))] { let _ = (user, state, remember_me); - Err(ApiError::Internal("JWT feature not enabled".to_string())) + Err(AppError(DomainError::InfrastructureError("JWT feature not enabled".to_string()))) } } diff --git a/crates/presentation/src/handlers/channels.rs b/crates/presentation/src/handlers/channels.rs index 501fee4..6fdc857 100644 --- a/crates/presentation/src/handlers/channels.rs +++ b/crates/presentation/src/handlers/channels.rs @@ -13,15 +13,16 @@ use application::config_snapshots::{ GetSnapshotQuery, ListSnapshotsQuery, PatchLabelCommand, RestoreSnapshotCommand, SaveSnapshotCommand, }; +use domain::DomainError; -use crate::errors::ApiError; +use crate::errors::AppError; use crate::extractors::CurrentUser; use crate::state::AppState; pub async fn list_channels( State(state): State, CurrentUser(_user): CurrentUser, -) -> Result>, ApiError> { +) -> Result>, AppError> { let channels = application::channels::list::execute(&state.channel_query_deps, ListChannelsQuery).await?; Ok(Json(channels.into_iter().map(ChannelResponse::from).collect())) @@ -30,7 +31,7 @@ pub async fn list_channels( pub async fn list_my_channels( State(state): State, CurrentUser(user): CurrentUser, -) -> Result>, ApiError> { +) -> Result>, AppError> { let query = ListByOwnerQuery { owner_id: user.id(), }; @@ -43,7 +44,7 @@ pub async fn create_channel( State(state): State, CurrentUser(user): CurrentUser, Json(req): Json, -) -> Result, ApiError> { +) -> Result, AppError> { let cmd = CreateChannelCommand { owner_id: user.id(), name: req.name, @@ -57,13 +58,13 @@ pub async fn get_channel( State(state): State, CurrentUser(_user): CurrentUser, Path(id): Path, -) -> Result, ApiError> { +) -> Result, AppError> { let query = GetChannelQuery { channel_id: id.into(), }; let channel = application::channels::get::execute(&state.channel_query_deps, query) .await? - .ok_or_else(|| ApiError::not_found(format!("Channel {id} not found")))?; + .ok_or_else(|| AppError(DomainError::NotFound(format!("Channel {id} not found"))))?; Ok(Json(ChannelResponse::from(channel))) } @@ -72,12 +73,12 @@ pub async fn update_channel( CurrentUser(user): CurrentUser, Path(id): Path, Json(req): Json, -) -> Result, ApiError> { +) -> Result, AppError> { let schedule_config = req .schedule_config .map(|v| { serde_json::from_value(v) - .map_err(|e| ApiError::validation(format!("Invalid schedule_config: {e}"))) + .map_err(|e| AppError(DomainError::ValidationError(format!("Invalid schedule_config: {e}")))) }) .transpose()?; @@ -85,7 +86,7 @@ pub async fn update_channel( .recycle_policy .map(|v| { serde_json::from_value(v) - .map_err(|e| ApiError::validation(format!("Invalid recycle_policy: {e}"))) + .map_err(|e| AppError(DomainError::ValidationError(format!("Invalid recycle_policy: {e}")))) }) .transpose()?; @@ -107,7 +108,7 @@ pub async fn delete_channel( State(state): State, CurrentUser(user): CurrentUser, Path(id): Path, -) -> Result { +) -> Result { let cmd = DeleteChannelCommand { channel_id: id.into(), owner_id: user.id(), @@ -120,7 +121,7 @@ pub async fn save_snapshot( State(state): State, CurrentUser(_user): CurrentUser, Path(id): Path, -) -> Result, ApiError> { +) -> Result, AppError> { let cmd = SaveSnapshotCommand { channel_id: id.into(), label: None, @@ -133,7 +134,7 @@ pub async fn list_snapshots( State(state): State, CurrentUser(_user): CurrentUser, Path(id): Path, -) -> Result>, ApiError> { +) -> Result>, AppError> { let query = ListSnapshotsQuery { channel_id: id.into(), }; @@ -145,14 +146,14 @@ pub async fn get_snapshot( State(state): State, CurrentUser(_user): CurrentUser, Path((id, snapshot_id)): Path<(uuid::Uuid, uuid::Uuid)>, -) -> Result, ApiError> { +) -> Result, AppError> { let query = GetSnapshotQuery { channel_id: id.into(), snapshot_id: snapshot_id.into(), }; let snap = application::config_snapshots::get::execute(&state.config_snapshot_deps, query) .await? - .ok_or_else(|| ApiError::not_found("Snapshot not found"))?; + .ok_or_else(|| AppError(DomainError::NotFound("Snapshot not found".into())))?; Ok(Json(ConfigSnapshotResponse::from(snap))) } @@ -161,7 +162,7 @@ pub async fn patch_snapshot( CurrentUser(_user): CurrentUser, Path((id, snapshot_id)): Path<(uuid::Uuid, uuid::Uuid)>, Json(req): Json, -) -> Result, ApiError> { +) -> Result, AppError> { let cmd = PatchLabelCommand { channel_id: id.into(), snapshot_id: snapshot_id.into(), @@ -170,7 +171,7 @@ pub async fn patch_snapshot( let snap = application::config_snapshots::patch_label::execute(&state.config_snapshot_deps, cmd) .await? - .ok_or_else(|| ApiError::not_found("Snapshot not found"))?; + .ok_or_else(|| AppError(DomainError::NotFound("Snapshot not found".into())))?; Ok(Json(ConfigSnapshotResponse::from(snap))) } @@ -178,7 +179,7 @@ pub async fn restore_snapshot( State(state): State, CurrentUser(_user): CurrentUser, Path((id, snapshot_id)): Path<(uuid::Uuid, uuid::Uuid)>, -) -> Result, ApiError> { +) -> Result, AppError> { let cmd = RestoreSnapshotCommand { channel_id: id.into(), snapshot_id: snapshot_id.into(), diff --git a/crates/presentation/src/handlers/config.rs b/crates/presentation/src/handlers/config.rs index daea43e..eabf276 100644 --- a/crates/presentation/src/handlers/config.rs +++ b/crates/presentation/src/handlers/config.rs @@ -3,14 +3,14 @@ use axum::extract::State; use api_types::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo}; -use crate::errors::ApiError; +use crate::errors::AppError; use crate::state::AppState; const FALLBACK_STREAMING_PROTOCOL: &str = "direct_file"; pub async fn get_config( State(state): State, -) -> Result, ApiError> { +) -> Result, AppError> { let registry = &state.provider_registry; let provider_ids = registry.provider_ids(); let primary_id = registry.primary_id().to_string(); diff --git a/crates/presentation/src/handlers/files.rs b/crates/presentation/src/handlers/files.rs index 96a793e..bd77d3e 100644 --- a/crates/presentation/src/handlers/files.rs +++ b/crates/presentation/src/handlers/files.rs @@ -2,22 +2,24 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::IntoResponse; -use crate::errors::ApiError; +use domain::DomainError; + +use crate::errors::AppError; use crate::state::AppState; pub async fn stream_file( State(_state): State, Path(_id): Path, -) -> Result { - Err::(ApiError::NotFound( +) -> Result { + Err::(AppError(DomainError::NotFound( "Local file streaming not yet wired in presentation crate".to_string(), - )) + ))) } pub async fn rescan( State(_state): State, -) -> Result { - Err::(ApiError::NotFound( +) -> Result { + Err::(AppError(DomainError::NotFound( "Local file rescan not yet wired in presentation crate".to_string(), - )) + ))) } diff --git a/crates/presentation/src/handlers/iptv.rs b/crates/presentation/src/handlers/iptv.rs index 3b9de46..e709908 100644 --- a/crates/presentation/src/handlers/iptv.rs +++ b/crates/presentation/src/handlers/iptv.rs @@ -1,27 +1,22 @@ use axum::extract::{Query, State}; use axum::http::header; use axum::response::IntoResponse; -use serde::Deserialize; +use api_types::IptvParams; use application::iptv::{GetM3uQuery, GetXmltvQuery}; -use crate::errors::ApiError; +use crate::errors::AppError; use crate::extractors::OptionalCurrentUser; use crate::state::AppState; const M3U_CONTENT_TYPE: &str = "audio/x-mpegurl; charset=utf-8"; const XML_CONTENT_TYPE: &str = "application/xml; charset=utf-8"; -#[derive(Debug, Deserialize)] -pub struct IptvParams { - pub token: Option, -} - pub async fn m3u_playlist( State(state): State, OptionalCurrentUser(_user): OptionalCurrentUser, Query(params): Query, -) -> Result { +) -> Result { let query = GetM3uQuery { base_url: state.config.base_url.clone(), token: params.token, @@ -33,7 +28,7 @@ pub async fn m3u_playlist( pub async fn xmltv_epg( State(state): State, OptionalCurrentUser(_user): OptionalCurrentUser, -) -> Result { +) -> Result { let content = application::iptv::xmltv::execute(&state.iptv_deps, GetXmltvQuery).await?; Ok(([(header::CONTENT_TYPE, XML_CONTENT_TYPE)], content)) } diff --git a/crates/presentation/src/handlers/library.rs b/crates/presentation/src/handlers/library.rs index ee6b7d3..8dcc44f 100644 --- a/crates/presentation/src/handlers/library.rs +++ b/crates/presentation/src/handlers/library.rs @@ -1,40 +1,28 @@ use axum::Json; use axum::extract::{Path, Query, State}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; -use api_types::{CollectionResponse, LibraryItemResponse, PaginatedResponse, SeasonResponse, ShowResponse}; +use api_types::{ + CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, PaginatedResponse, + ProviderParam, SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, +}; use application::library::{ GetItemQuery, GetSyncStatusQuery, ListCollectionsQuery, ListGenresQuery, ListSeasonsQuery, ListShowsQuery, SearchItemsQuery, TriggerSyncCommand, }; +use domain::DomainError; -use crate::errors::ApiError; +use crate::errors::AppError; use crate::extractors::{AdminUser, CurrentUser}; use crate::state::AppState; const DEFAULT_SEARCH_LIMIT: u32 = 50; -#[derive(Debug, Deserialize)] -pub struct LibrarySearchParams { - pub provider: Option, - pub content_type: Option, - #[serde(default, rename = "genres[]")] - pub genres: Vec, - pub search_term: Option, - pub collection_id: Option, - #[serde(default, rename = "series_names[]")] - pub series_names: Vec, - pub season_number: Option, - pub decade: Option, - pub offset: Option, - pub limit: Option, -} - pub async fn search_items( State(state): State, CurrentUser(_user): CurrentUser, Query(params): Query, -) -> Result>, ApiError> { +) -> Result>, AppError> { let query = SearchItemsQuery { provider_id: params.provider, content_type: params.content_type, @@ -58,11 +46,11 @@ pub async fn get_item( State(state): State, CurrentUser(_user): CurrentUser, Path(id): Path, -) -> Result, ApiError> { +) -> Result, AppError> { let query = GetItemQuery { item_id: id.clone() }; let item = application::library::get_item::execute(&state.library_query_deps, query) .await? - .ok_or_else(|| ApiError::not_found(format!("Library item {id} not found")))?; + .ok_or_else(|| AppError(DomainError::NotFound(format!("Library item {id} not found"))))?; Ok(Json(LibraryItemResponse::from(item))) } @@ -70,7 +58,7 @@ pub async fn list_collections( State(state): State, CurrentUser(_user): CurrentUser, Query(params): Query, -) -> Result>, ApiError> { +) -> Result>, AppError> { let query = ListCollectionsQuery { provider_id: params.provider, }; @@ -84,24 +72,11 @@ pub async fn list_collections( )) } -#[derive(Debug, Deserialize)] -pub struct ProviderParam { - pub provider: Option, -} - -#[derive(Debug, Deserialize)] -pub struct ShowsParams { - pub provider: Option, - pub search_term: Option, - #[serde(default, rename = "genres[]")] - pub genres: Vec, -} - pub async fn list_shows( State(state): State, CurrentUser(_user): CurrentUser, Query(params): Query, -) -> Result>, ApiError> { +) -> Result>, AppError> { let query = ListShowsQuery { provider_id: params.provider, search_term: params.search_term, @@ -111,17 +86,11 @@ pub async fn list_shows( Ok(Json(shows.into_iter().map(ShowResponse::from).collect())) } -#[derive(Debug, Deserialize)] -pub struct SeasonsParams { - pub series_name: String, - pub provider: Option, -} - pub async fn list_seasons( State(state): State, CurrentUser(_user): CurrentUser, Query(params): Query, -) -> Result>, ApiError> { +) -> Result>, AppError> { let query = ListSeasonsQuery { series_name: params.series_name, provider_id: params.provider, @@ -133,17 +102,11 @@ pub async fn list_seasons( )) } -#[derive(Debug, Deserialize)] -pub struct GenresParams { - pub content_type: Option, - pub provider: Option, -} - pub async fn list_genres( State(state): State, CurrentUser(_user): CurrentUser, Query(params): Query, -) -> Result>, ApiError> { +) -> Result>, AppError> { let query = ListGenresQuery { content_type: params.content_type, provider_id: params.provider, @@ -166,7 +129,7 @@ pub(crate) struct SyncStatusEntry { pub async fn sync_status( State(state): State, CurrentUser(_user): CurrentUser, -) -> Result>, ApiError> { +) -> Result>, AppError> { let entries = application::library::get_sync_status::execute(&state.library_query_deps, GetSyncStatusQuery) .await?; @@ -187,15 +150,15 @@ pub async fn sync_status( pub async fn trigger_sync( State(state): State, AdminUser(_user): AdminUser, -) -> Result { +) -> Result { let cmd = TriggerSyncCommand { provider_id: None }; application::library::sync::execute(&state.library_command_deps, cmd) .await .map_err(|e| { if e.to_string().contains("already running") { - ApiError::conflict(e.to_string()) + AppError(DomainError::Conflict(e.to_string())) } else { - ApiError::from(e) + AppError::from(e) } })?; diff --git a/crates/presentation/src/handlers/providers.rs b/crates/presentation/src/handlers/providers.rs index b7d7564..1e889b8 100644 --- a/crates/presentation/src/handlers/providers.rs +++ b/crates/presentation/src/handlers/providers.rs @@ -5,15 +5,16 @@ use api_types::{ProviderConfigRequest, ProviderConfigResponse}; use application::providers::{ DeleteProviderCommand, GetProviderQuery, ListProvidersQuery, UpsertProviderCommand, }; +use domain::DomainError; -use crate::errors::ApiError; +use crate::errors::AppError; use crate::extractors::AdminUser; use crate::state::AppState; pub async fn list_providers( State(state): State, AdminUser(_user): AdminUser, -) -> Result>, ApiError> { +) -> Result>, AppError> { let providers = application::providers::list::execute(&state.provider_deps, ListProvidersQuery).await?; Ok(Json( @@ -28,11 +29,11 @@ pub async fn get_provider( State(state): State, AdminUser(_user): AdminUser, Path(id): Path, -) -> Result, ApiError> { +) -> Result, AppError> { let query = GetProviderQuery { id: id.clone() }; let provider = application::providers::get::execute(&state.provider_deps, query) .await? - .ok_or_else(|| ApiError::not_found(format!("Provider {id} not found")))?; + .ok_or_else(|| AppError(DomainError::NotFound(format!("Provider {id} not found"))))?; Ok(Json(ProviderConfigResponse::from(provider))) } @@ -41,9 +42,9 @@ pub async fn upsert_provider( AdminUser(_user): AdminUser, Path(id): Path, Json(req): Json, -) -> Result, ApiError> { +) -> Result, AppError> { let config_json = serde_json::to_string(&req.config) - .map_err(|e| ApiError::validation(format!("Invalid config JSON: {e}")))?; + .map_err(|e| AppError(DomainError::ValidationError(format!("Invalid config JSON: {e}"))))?; let cmd = UpsertProviderCommand { id, @@ -59,7 +60,7 @@ pub async fn delete_provider( State(state): State, AdminUser(_user): AdminUser, Path(id): Path, -) -> Result { +) -> Result { let cmd = DeleteProviderCommand { id }; application::providers::delete::execute(&state.provider_deps, cmd).await?; Ok(axum::http::StatusCode::NO_CONTENT) diff --git a/crates/presentation/src/handlers/schedule.rs b/crates/presentation/src/handlers/schedule.rs index 584dfd0..63442fc 100644 --- a/crates/presentation/src/handlers/schedule.rs +++ b/crates/presentation/src/handlers/schedule.rs @@ -11,7 +11,7 @@ use application::schedule::{ GetStreamUrlQuery, ListHistoryQuery, }; -use crate::errors::ApiError; +use crate::errors::AppError; use crate::extractors::CurrentUser; use crate::state::AppState; @@ -19,7 +19,7 @@ pub async fn generate_schedule( State(state): State, CurrentUser(_user): CurrentUser, Path(id): Path, -) -> Result, ApiError> { +) -> Result, AppError> { let cmd = GenerateScheduleCommand { channel_id: id }; let schedule = application::schedule::generate::execute(&state.schedule_deps, cmd).await?; Ok(Json(ScheduleResponse::from(schedule))) @@ -29,7 +29,7 @@ pub async fn get_active_schedule( State(state): State, CurrentUser(_user): CurrentUser, Path(id): Path, -) -> Result { +) -> Result { let query = GetActiveScheduleQuery { channel_id: id }; match application::schedule::get_active::execute(&state.schedule_deps, query).await? { Some(schedule) => Ok(Json(ScheduleResponse::from(schedule)).into_response()), @@ -40,7 +40,7 @@ pub async fn get_active_schedule( pub async fn get_current_broadcast( State(state): State, Path(id): Path, -) -> Result { +) -> Result { let query = GetCurrentBroadcastQuery { channel_id: id }; match application::schedule::get_current_broadcast::execute(&state.schedule_deps, query).await? { @@ -69,7 +69,7 @@ pub async fn get_current_broadcast( pub async fn get_epg( State(state): State, Path(id): Path, -) -> Result>, ApiError> { +) -> Result>, AppError> { let query = GetEpgQuery { channel_id: id }; let slots = application::schedule::get_epg::execute(&state.schedule_deps, query).await?; Ok(Json(slots.into_iter().map(SlotResponse::from).collect())) @@ -78,7 +78,7 @@ pub async fn get_epg( pub async fn get_stream( State(state): State, Path(id): Path, -) -> Result { +) -> Result { let broadcast_query = GetCurrentBroadcastQuery { channel_id: id }; let broadcast = application::schedule::get_current_broadcast::execute(&state.schedule_deps, broadcast_query) @@ -107,7 +107,7 @@ pub async fn list_schedule_history( State(state): State, CurrentUser(_user): CurrentUser, Path(id): Path, -) -> Result>, ApiError> { +) -> Result>, AppError> { let query = ListHistoryQuery { channel_id: id }; let history = application::schedule::list_history::execute(&state.schedule_deps, query).await?;