start massive cleanup

This commit is contained in:
Lukas Kreussel
2025-11-30 21:45:48 +01:00
parent b5fca3401d
commit a27c199a80
16 changed files with 1119 additions and 789 deletions

357
Cargo.lock generated
View File

@@ -2,21 +2,47 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "addr2line"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aho-corasick"
version = "1.1.3"
@@ -147,6 +173,16 @@ dependencies = [
"nom",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "async-compression"
version = "0.4.27"
@@ -358,21 +394,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "backtrace"
version = "0.3.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-targets 0.52.6",
]
[[package]]
name = "base64"
version = "0.22.1"
@@ -493,7 +514,17 @@ dependencies = [
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
"windows-link 0.1.3",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
@@ -671,9 +702,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "darling"
version = "0.20.11"
@@ -758,6 +799,24 @@ dependencies = [
"parking_lot_core",
]
[[package]]
name = "deadpool"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
dependencies = [
"deadpool-runtime",
"lazy_static",
"num_cpus",
"tokio",
]
[[package]]
name = "deadpool-runtime"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
[[package]]
name = "der"
version = "0.7.10"
@@ -974,6 +1033,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
@@ -1053,6 +1113,7 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -1102,10 +1163,14 @@ dependencies = [
]
[[package]]
name = "gimli"
version = "0.31.1"
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "h2"
@@ -1170,6 +1235,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@@ -1260,13 +1331,14 @@ dependencies = [
[[package]]
name = "hyper"
version = "1.6.0"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-util",
"futures-core",
"h2",
"http",
"http-body",
@@ -1274,6 +1346,7 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
"want",
@@ -1314,7 +1387,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"socket2 0.5.10",
"system-configuration",
"tokio",
"tower-service",
@@ -1483,14 +1556,12 @@ dependencies = [
]
[[package]]
name = "io-uring"
version = "0.7.8"
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"bitflags",
"cfg-if",
"libc",
"generic-array",
]
[[package]]
@@ -1515,6 +1586,20 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jellyfin-api"
version = "0.1.0"
dependencies = [
"reqwest",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"tracing",
"url",
"wiremock",
]
[[package]]
name = "jellyswarrm-macros"
version = "0.1.5"
@@ -1531,6 +1616,7 @@ dependencies = [
name = "jellyswarrm-proxy"
version = "0.1.5"
dependencies = [
"aes-gcm",
"anyhow",
"askama",
"askama_axum",
@@ -1549,6 +1635,7 @@ dependencies = [
"hyper",
"hyper-util",
"indexmap 2.12.1",
"jellyfin-api",
"jellyswarrm-macros",
"mime_guess",
"mockall",
@@ -1867,12 +1954,13 @@ dependencies = [
]
[[package]]
name = "object"
version = "0.36.7"
name = "num_cpus"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"memchr",
"hermit-abi",
"libc",
]
[[package]]
@@ -1881,6 +1969,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "ordered-float"
version = "2.10.1"
@@ -2068,6 +2162,18 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.11.1"
@@ -2146,7 +2252,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"socket2 0.5.10",
"thiserror 2.0.12",
"tokio",
"tracing",
@@ -2183,7 +2289,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"socket2 0.5.10",
"tracing",
"windows-sys 0.59.0",
]
@@ -2491,12 +2597,6 @@ dependencies = [
"ordered-multimap",
]
[[package]]
name = "rustc-demangle"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f"
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -2849,6 +2949,16 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
dependencies = [
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "spin"
version = "0.9.8"
@@ -3288,29 +3398,26 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.46.1"
version = "1.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17"
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
dependencies = [
"backtrace",
"bytes",
"io-uring",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2",
"socket2 0.6.1",
"tokio-macros",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.5.0"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
@@ -3695,6 +3802,16 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -3965,7 +4082,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-link 0.1.3",
"windows-result",
"windows-strings",
]
@@ -3998,13 +4115,19 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link",
"windows-link 0.1.3",
"windows-result",
"windows-strings",
]
@@ -4015,7 +4138,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link",
"windows-link 0.1.3",
]
[[package]]
@@ -4024,7 +4147,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link",
"windows-link 0.1.3",
]
[[package]]
@@ -4054,6 +4177,24 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
@@ -4078,13 +4219,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -4097,6 +4255,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
@@ -4109,6 +4273,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
@@ -4121,12 +4291,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
@@ -4139,6 +4321,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
@@ -4151,6 +4339,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
@@ -4163,6 +4357,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
@@ -4175,6 +4375,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.14"
@@ -4184,6 +4390,29 @@ dependencies = [
"memchr",
]
[[package]]
name = "wiremock"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [
"assert-json-diff",
"base64",
"deadpool",
"futures",
"http",
"http-body-util",
"hyper",
"hyper-util",
"log",
"once_cell",
"regex",
"serde",
"serde_json",
"tokio",
"url",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"

View File

@@ -2,6 +2,7 @@
members = [
"crates/jellyswarrm-proxy",
"crates/jellyswarrm-macros",
"crates/jellyfin-api",
]
resolver = "2"

View File

@@ -0,0 +1,16 @@
[package]
name = "jellyfin-api"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
url = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
wiremock = "0.6"

View File

@@ -0,0 +1,23 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("Network error: {0}")]
Network(#[from] reqwest::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("URL parse error: {0}")]
UrlParse(#[from] url::ParseError),
#[error("Authentication failed: {0}")]
AuthenticationFailed(String),
#[error("Unauthorized")]
Unauthorized,
#[error("Forbidden")]
Forbidden,
#[error("Not found")]
NotFound,
#[error("Server error: {0}")]
ServerError(String),
#[error("Invalid response: {0}")]
InvalidResponse(String),
}

View File

@@ -0,0 +1,305 @@
pub mod error;
pub mod models;
use error::Error;
use models::{AuthResponse, MediaFoldersResponse, User};
use reqwest::{header, Client, StatusCode};
use serde::de::DeserializeOwned;
use serde_json::json;
use std::sync::{Arc, RwLock};
use url::Url;
#[derive(Debug, Clone)]
pub struct ClientInfo {
pub client: String,
pub device: String,
pub device_id: String,
pub version: String,
}
impl Default for ClientInfo {
fn default() -> Self {
Self {
client: "Jellyfin API Client".to_string(),
device: "Unknown".to_string(),
device_id: "unknown-device-id".to_string(),
version: "0.0.0".to_string(),
}
}
}
#[derive(Clone)]
pub struct JellyfinClient {
base_url: Url,
client_info: ClientInfo,
http_client: Client,
auth_token: Arc<RwLock<Option<String>>>,
}
impl JellyfinClient {
pub fn new(base_url: &str, client_info: ClientInfo) -> Result<Self, Error> {
let mut url = Url::parse(base_url)?;
// Ensure no trailing slash for consistent joining
if url.path().ends_with('/') {
url.path_segments_mut()
.map_err(|_| Error::UrlParse(url::ParseError::EmptyHost))?
.pop_if_empty();
}
let http_client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
Ok(Self {
base_url: url,
client_info,
http_client,
auth_token: Arc::new(RwLock::new(None)),
})
}
pub fn with_token(self, token: String) -> Self {
*self.auth_token.write().unwrap() = Some(token);
self
}
pub fn get_token(&self) -> Option<String> {
self.auth_token.read().unwrap().clone()
}
fn build_auth_header(&self) -> String {
let mut header = format!(
"MediaBrowser Client=\"{}\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\"",
self.client_info.client,
self.client_info.device,
self.client_info.device_id,
self.client_info.version
);
if let Some(token) = self.auth_token.read().unwrap().as_ref() {
header.push_str(&format!(", Token=\"{}\"", token));
}
// println!("DEBUG HEADER: {}", header);
header
}
async fn request<T: DeserializeOwned>(
&self,
method: reqwest::Method,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<T, Error> {
let url = self.base_url.join(path)?;
let auth_header = self.build_auth_header();
let mut request = self
.http_client
.request(method, url)
.header(header::AUTHORIZATION, auth_header);
if let Some(b) = body {
request = request.json(b);
}
let response = request.send().await?;
let status = response.status();
if status.is_success() {
let data = response.json::<T>().await?;
Ok(data)
} else {
match status {
StatusCode::UNAUTHORIZED => Err(Error::Unauthorized),
StatusCode::FORBIDDEN => Err(Error::Forbidden),
StatusCode::NOT_FOUND => Err(Error::NotFound),
_ => {
let text = response.text().await.unwrap_or_default();
Err(Error::ServerError(format!("{} - {}", status, text)))
}
}
}
}
async fn request_no_content(
&self,
method: reqwest::Method,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<(), Error> {
let url = self.base_url.join(path)?;
let auth_header = self.build_auth_header();
let mut request = self
.http_client
.request(method, url)
.header(header::AUTHORIZATION, auth_header);
if let Some(b) = body {
request = request.json(b);
}
let response = request.send().await?;
let status = response.status();
if status.is_success() {
Ok(())
} else {
match status {
StatusCode::UNAUTHORIZED => Err(Error::Unauthorized),
StatusCode::FORBIDDEN => Err(Error::Forbidden),
StatusCode::NOT_FOUND => Err(Error::NotFound),
_ => {
let text = response.text().await.unwrap_or_default();
Err(Error::ServerError(format!("{} - {}", status, text)))
}
}
}
}
pub async fn authenticate_by_name(
&self,
username: &str,
password: &str,
) -> Result<User, Error> {
let body = json!({
"Username": username,
"Pw": password
});
let response: AuthResponse = self
.request(
reqwest::Method::POST,
"Users/AuthenticateByName",
Some(&body),
)
.await
.map_err(|e| match e {
Error::Unauthorized => {
Error::AuthenticationFailed("Invalid credentials".to_string())
}
_ => e,
})?;
*self.auth_token.write().unwrap() = Some(response.access_token);
Ok(response.user)
}
pub async fn logout(&self) -> Result<(), Error> {
self.request_no_content(reqwest::Method::POST, "Sessions/Logout", None)
.await?;
*self.auth_token.write().unwrap() = None;
Ok(())
}
pub async fn get_me(&self) -> Result<User, Error> {
self.request(reqwest::Method::GET, "Users/Me", None).await
}
pub async fn get_media_folders(&self) -> Result<Vec<models::MediaFolder>, Error> {
let response: MediaFoldersResponse = self
.request(reqwest::Method::GET, "Library/MediaFolders", None)
.await?;
Ok(response.items)
}
pub async fn get_public_system_info(&self) -> Result<models::PublicSystemInfo, Error> {
self.request(reqwest::Method::GET, "System/Info/Public", None)
.await
}
// Admin methods
pub async fn get_users(&self) -> Result<Vec<User>, Error> {
self.request(reqwest::Method::GET, "Users", None).await
}
pub async fn create_user(&self, username: &str, password: Option<&str>) -> Result<User, Error> {
let body = json!({
"Name": username,
"Password": password
});
let user: User = self
.request(reqwest::Method::POST, "Users/New", Some(&body))
.await?;
Ok(user)
}
pub async fn delete_user(&self, user_id: &str) -> Result<(), Error> {
let path = format!("Users/{}", user_id);
self.request_no_content(reqwest::Method::DELETE, &path, None)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn test_authenticate_success() {
let mock_server = MockServer::start().await;
let auth_response = json!({
"AccessToken": "test_token",
"User": {
"Id": "user_id",
"Name": "test_user",
"ServerId": "server_id"
}
});
Mock::given(method("POST"))
.and(path("/Users/AuthenticateByName"))
.respond_with(ResponseTemplate::new(200).set_body_json(auth_response))
.mount(&mock_server)
.await;
let client_info = ClientInfo::default();
let client = JellyfinClient::new(&mock_server.uri(), client_info).unwrap();
let user = client
.authenticate_by_name("test_user", "password")
.await
.unwrap();
assert_eq!(user.name, "test_user");
assert_eq!(client.get_token().as_deref(), Some("test_token"));
}
#[tokio::test]
async fn test_get_media_folders() {
let mock_server = MockServer::start().await;
let folders_response = json!({
"Items": [
{
"Name": "Movies",
"CollectionType": "movies",
"Id": "folder_1"
}
]
});
Mock::given(method("GET"))
.and(path("/Library/MediaFolders"))
//.and(header("Authorization", "MediaBrowser Client=\"Jellyfin API Client\", Device=\"Unknown\", DeviceId=\"unknown-device-id\", Version=\"0.0.0\", Token=\"test_token\""))
.respond_with(ResponseTemplate::new(200).set_body_json(folders_response))
.mount(&mock_server)
.await;
let client_info = ClientInfo::default();
let client = JellyfinClient::new(&mock_server.uri(), client_info)
.unwrap()
.with_token("test_token".to_string());
let folders = client.get_media_folders().await.unwrap();
assert_eq!(folders.len(), 1);
assert_eq!(folders[0].name, "Movies");
}
}

View File

@@ -0,0 +1,67 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserPolicy {
#[serde(rename = "IsAdministrator")]
pub is_administrator: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
#[serde(rename = "Id")]
pub id: String,
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "ServerId")]
pub server_id: Option<String>,
#[serde(rename = "Policy")]
pub policy: Option<UserPolicy>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthResponse {
#[serde(rename = "AccessToken")]
pub access_token: String,
#[serde(rename = "User")]
pub user: User,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaFolder {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "CollectionType")]
pub collection_type: Option<String>,
#[serde(rename = "Id")]
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaFoldersResponse {
#[serde(rename = "Items")]
pub items: Vec<MediaFolder>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewUserRequest {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Password")]
pub password: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublicSystemInfo {
#[serde(rename = "LocalAddress")]
pub local_address: Option<String>,
#[serde(rename = "ServerName")]
pub server_name: Option<String>,
#[serde(rename = "Version")]
pub version: Option<String>,
#[serde(rename = "ProductName")]
pub product_name: Option<String>,
#[serde(rename = "Id")]
pub id: Option<String>,
#[serde(rename = "StartupWizardCompleted")]
pub startup_wizard_completed: Option<bool>,
}

View File

@@ -61,6 +61,7 @@ async-recursion = "1.1.1"
moka = { version = "0.12.11", features = ["future"] }
aes-gcm = "0.10.3"
serde_with = "3.16.1"
jellyfin-api = { path = "../jellyfin-api" }
[dev-dependencies]
tokio-test = "0.4.4"

View File

@@ -9,12 +9,20 @@ use tower_sessions::cookie::Key;
use tracing::info;
use uuid::Uuid;
use jellyfin_api::ClientInfo;
use once_cell::sync::Lazy;
use base64::prelude::*;
pub static MIGRATOR: Migrator = sqlx::migrate!();
pub static CLIENT_INFO: Lazy<ClientInfo> = Lazy::new(|| ClientInfo {
client: "Jellyswarrm Proxy".to_string(),
device: "Server".to_string(),
device_id: "jellyswarrm-proxy".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
});
// Lazily-resolved data directory shared across the application.
// Priority: env var JELLYSWARRM_DATA_DIR, else "./data" relative to current working dir.
// The directory is created on first access.

View File

@@ -1,12 +1,12 @@
use std::sync::Arc;
use serde::Deserialize;
use tracing::{error, info, warn};
use crate::{
encryption::decrypt_password, server_storage::ServerStorageService,
user_authorization_service::UserAuthorizationService, AppState,
};
use jellyfin_api::JellyfinClient;
#[derive(Debug, Clone)]
pub enum SyncStatus {
@@ -30,22 +30,14 @@ pub struct ServerSyncResult {
pub struct FederatedUserService {
server_storage: Arc<ServerStorageService>,
user_authorization: Arc<UserAuthorizationService>,
reqwest_client: reqwest::Client,
config: Arc<tokio::sync::RwLock<crate::config::AppConfig>>,
}
#[derive(Deserialize)]
struct NewUserResponse {
#[serde(rename = "Id")]
id: String,
}
impl FederatedUserService {
pub fn new(state: &AppState) -> Self {
Self {
server_storage: state.server_storage.clone(),
user_authorization: state.user_authorization.clone(),
reqwest_client: state.reqwest_client.clone(),
config: state.config.clone(),
}
}
@@ -53,13 +45,11 @@ impl FederatedUserService {
pub fn new_from_components(
server_storage: Arc<ServerStorageService>,
user_authorization: Arc<UserAuthorizationService>,
reqwest_client: reqwest::Client,
config: Arc<tokio::sync::RwLock<crate::config::AppConfig>>,
) -> Self {
Self {
server_storage,
user_authorization,
reqwest_client,
config,
}
}
@@ -116,16 +106,27 @@ impl FederatedUserService {
}
};
let client_info = crate::config::CLIENT_INFO.clone();
let client = match JellyfinClient::new(server.url.as_str(), client_info.clone()) {
Ok(c) => c,
Err(e) => {
error!("Failed to create jellyfin client: {}", e);
results.push(ServerSyncResult {
server_name: server.name.clone(),
status: SyncStatus::Failed,
message: Some(format!("Client error: {}", e)),
});
continue;
}
};
// Authenticate as admin to get token
let auth_token = match self
.authenticate_as_admin(
server.url.as_ref(),
&admin.username,
&decrypted_admin_password,
)
match client
.authenticate_by_name(&admin.username, &decrypted_admin_password)
.await
{
Ok(token) => token,
Ok(_) => {}
Err(e) => {
error!(
"Failed to authenticate as admin on server {}: {}",
@@ -140,39 +141,86 @@ impl FederatedUserService {
}
};
// Check if user exists, if not create
match self
.create_user_on_server(server.url.as_ref(), &auth_token, username, password)
.await
{
Ok((remote_user_id, created)) => {
let (status, should_map) = if created {
(SyncStatus::Created, true)
} else {
// User exists. Check if password matches.
match self
.check_user_password(server.url.as_ref(), username, password)
.await
{
Ok(true) => (SyncStatus::AlreadyExists, true),
Ok(false) => (SyncStatus::ExistsWithDifferentPassword, false),
Err(e) => {
warn!(
"Failed to check password for existing user {} on {}: {}",
username, server.name, e
);
// Assume mismatch or failure, don't map to be safe
(SyncStatus::ExistsWithDifferentPassword, false)
}
}
// Check if user exists
let users = match client.get_users().await {
Ok(u) => u,
Err(e) => {
error!("Failed to list users on server {}: {}", server.name, e);
results.push(ServerSyncResult {
server_name: server.name.clone(),
status: SyncStatus::Failed,
message: Some(format!("Failed to list users: {}", e)),
});
continue;
}
};
let existing_user = users.iter().find(|u| u.name.eq_ignore_ascii_case(username));
if let Some(remote_user) = existing_user {
// User exists. Check if password matches.
// We need a new client to check user password
let user_client =
match JellyfinClient::new(server.url.as_str(), client_info.clone()) {
Ok(c) => c,
Err(_) => continue,
};
info!(
"Synced user {} to server {} (Remote ID: {}, Status: {:?})",
username, server.name, remote_user_id, status
);
let (status, should_map) =
match user_client.authenticate_by_name(username, password).await {
Ok(_) => (SyncStatus::AlreadyExists, true),
Err(_) => (SyncStatus::ExistsWithDifferentPassword, false),
};
info!(
"Synced user {} to server {} (Remote ID: {}, Status: {:?})",
username, server.name, remote_user.id, status
);
if should_map {
if let Err(e) = self
.user_authorization
.add_server_mapping(
user_id,
server.url.as_str(),
username,
password,
Some(password), // Encrypt with their own password so they can use it
)
.await
{
error!(
"Failed to create local mapping for synced user on server {}: {}",
server.name, e
);
results.push(ServerSyncResult {
server_name: server.name.clone(),
status: SyncStatus::Failed,
message: Some(format!("Failed to save local mapping: {}", e)),
});
} else {
results.push(ServerSyncResult {
server_name: server.name.clone(),
status,
message: None,
});
}
} else {
results.push(ServerSyncResult {
server_name: server.name.clone(),
status,
message: Some("User exists with different password".to_string()),
});
}
} else {
// Create user
match client.create_user(username, Some(password)).await {
Ok(new_user) => {
info!(
"Synced user {} to server {} (Remote ID: {}, Status: Created)",
username, server.name, new_user.id
);
if should_map {
if let Err(e) = self
.user_authorization
.add_server_mapping(
@@ -196,29 +244,23 @@ impl FederatedUserService {
} else {
results.push(ServerSyncResult {
server_name: server.name.clone(),
status,
status: SyncStatus::Created,
message: None,
});
}
} else {
}
Err(e) => {
warn!(
"Failed to sync user {} to server {}: {}",
username, server.name, e
);
results.push(ServerSyncResult {
server_name: server.name.clone(),
status,
message: Some("User exists with different password".to_string()),
status: SyncStatus::Failed,
message: Some(format!("Sync failed: {}", e)),
});
}
}
Err(e) => {
warn!(
"Failed to sync user {} to server {}: {}",
username, server.name, e
);
results.push(ServerSyncResult {
server_name: server.name.clone(),
status: SyncStatus::Failed,
message: Some(format!("Sync failed: {}", e)),
});
}
}
} else {
warn!(
@@ -278,15 +320,26 @@ impl FederatedUserService {
}
};
let auth_token = match self
.authenticate_as_admin(
server.url.as_ref(),
&admin.username,
&decrypted_admin_password,
)
let client_info = crate::config::CLIENT_INFO.clone();
let client = match JellyfinClient::new(server.url.as_str(), client_info.clone()) {
Ok(c) => c,
Err(e) => {
error!("Failed to create jellyfin client: {}", e);
results.push(ServerSyncResult {
server_name: server.name.clone(),
status: SyncStatus::Failed,
message: Some(format!("Client error: {}", e)),
});
continue;
}
};
match client
.authenticate_by_name(&admin.username, &decrypted_admin_password)
.await
{
Ok(token) => token,
Ok(_) => {}
Err(e) => {
error!(
"Failed to authenticate as admin on server {}: {}",
@@ -301,37 +354,56 @@ impl FederatedUserService {
}
};
match self
.delete_user_on_server(server.url.as_ref(), &auth_token, username)
.await
{
Ok(deleted) => {
let status = if deleted {
SyncStatus::Deleted
} else {
SyncStatus::NotFound
};
info!(
"Deleted user {} from server {} (Deleted: {})",
username, server.name, deleted
);
results.push(ServerSyncResult {
server_name: server.name.clone(),
status,
message: None,
});
}
// Find user ID
let users = match client.get_users().await {
Ok(u) => u,
Err(e) => {
warn!(
"Failed to delete user {} from server {}: {}",
username, server.name, e
);
error!("Failed to list users on server {}: {}", server.name, e);
results.push(ServerSyncResult {
server_name: server.name.clone(),
status: SyncStatus::Failed,
message: Some(format!("Delete failed: {}", e)),
message: Some(format!("Failed to list users: {}", e)),
});
continue;
}
};
let user_id = users
.iter()
.find(|u| u.name.eq_ignore_ascii_case(username))
.map(|u| u.id.clone());
if let Some(id) = user_id {
match client.delete_user(&id).await {
Ok(_) => {
info!(
"Deleted user {} from server {} (Deleted: true)",
username, server.name
);
results.push(ServerSyncResult {
server_name: server.name.clone(),
status: SyncStatus::Deleted,
message: None,
});
}
Err(e) => {
warn!(
"Failed to delete user {} from server {}: {}",
username, server.name, e
);
results.push(ServerSyncResult {
server_name: server.name.clone(),
status: SyncStatus::Failed,
message: Some(format!("Delete failed: {}", e)),
});
}
}
} else {
results.push(ServerSyncResult {
server_name: server.name.clone(),
status: SyncStatus::NotFound,
message: None,
});
}
} else {
results.push(ServerSyncResult {
@@ -344,224 +416,4 @@ impl FederatedUserService {
results
}
async fn check_user_password(
&self,
server_url: &str,
username: &str,
password: &str,
) -> Result<bool, anyhow::Error> {
let auth_url = format!(
"{}/Users/AuthenticateByName",
server_url.trim_end_matches('/')
);
let auth_header = format!(
"MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\"",
env!("CARGO_PKG_VERSION")
);
let response = self
.reqwest_client
.post(&auth_url)
.header("Authorization", auth_header)
.json(&serde_json::json!({
"Username": username,
"Pw": password
}))
.send()
.await?;
if response.status().is_success() {
Ok(true)
} else if response.status() == reqwest::StatusCode::UNAUTHORIZED {
Ok(false)
} else {
Err(anyhow::anyhow!(
"Authentication check failed: {}",
response.status()
))
}
}
async fn authenticate_as_admin(
&self,
server_url: &str,
username: &str,
password: &str,
) -> Result<String, anyhow::Error> {
let auth_url = format!(
"{}/Users/AuthenticateByName",
server_url.trim_end_matches('/')
);
let auth_header = format!(
"MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\"",
env!("CARGO_PKG_VERSION")
);
let response = self
.reqwest_client
.post(&auth_url)
.header("Authorization", auth_header)
.json(&serde_json::json!({
"Username": username,
"Pw": password
}))
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Authentication failed: {}",
response.status()
));
}
#[derive(Deserialize)]
struct AuthResponse {
#[serde(rename = "AccessToken")]
access_token: String,
}
let auth_response: AuthResponse = response.json().await?;
Ok(auth_response.access_token)
}
async fn create_user_on_server(
&self,
server_url: &str,
token: &str,
username: &str,
password: &str,
) -> Result<(String, bool), anyhow::Error> {
let base_url = server_url.trim_end_matches('/');
// 1. Check if user exists
let users_url = format!("{}/Users", base_url);
let auth_header = format!(
"MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\", Token=\"{}\"",
env!("CARGO_PKG_VERSION"),
token
);
let response = self
.reqwest_client
.get(&users_url)
.header("Authorization", &auth_header)
.send()
.await?;
if response.status().is_success() {
let users: Vec<serde_json::Value> = response.json().await?;
if let Some(existing) = users.iter().find(|u| {
u.get("Name")
.and_then(|n| n.as_str())
.map(|n| n.eq_ignore_ascii_case(username))
.unwrap_or(false)
}) {
// User exists, return their ID
return Ok((
existing
.get("Id")
.and_then(|i| i.as_str())
.unwrap_or_default()
.to_string(),
false,
));
}
}
// 2. Create user
let create_url = format!("{}/Users/New", base_url);
let response = self
.reqwest_client
.post(&create_url)
.header("Authorization", &auth_header)
.json(&serde_json::json!({
"Name": username,
"Password": password
}))
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"Failed to create user: {} - {}",
status,
text
));
}
let new_user: NewUserResponse = response.json().await?;
// Password should be set by the New endpoint if provided.
Ok((new_user.id, true))
}
async fn delete_user_on_server(
&self,
server_url: &str,
token: &str,
username: &str,
) -> Result<bool, anyhow::Error> {
let base_url = server_url.trim_end_matches('/');
// 1. Find user ID
let users_url = format!("{}/Users", base_url);
let auth_header = format!(
"MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\", Token=\"{}\"",
env!("CARGO_PKG_VERSION"),
token
);
let response = self
.reqwest_client
.get(&users_url)
.header("Authorization", &auth_header)
.send()
.await?;
let user_id = if response.status().is_success() {
let users: Vec<serde_json::Value> = response.json().await?;
users
.iter()
.find(|u| {
u.get("Name")
.and_then(|n| n.as_str())
.map(|n| n.eq_ignore_ascii_case(username))
.unwrap_or(false)
})
.and_then(|u| u.get("Id").and_then(|i| i.as_str()).map(|s| s.to_string()))
} else {
return Err(anyhow::anyhow!(
"Failed to list users: {}",
response.status()
));
};
if let Some(id) = user_id {
// 2. Delete user
let delete_url = format!("{}/Users/{}", base_url, id);
let response = self
.reqwest_client
.delete(&delete_url)
.header("Authorization", &auth_header)
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to delete user: {}",
response.status()
));
}
Ok(true)
} else {
Ok(false)
}
}
}

View File

@@ -83,7 +83,6 @@ impl AppState {
let federated_users = Arc::new(FederatedUserService::new_from_components(
data_context.server_storage.clone(),
data_context.user_authorization.clone(),
reqwest_client.clone(),
data_context.config.clone(),
));
@@ -113,6 +112,11 @@ impl AppState {
config.url_prefix.as_ref().map(|prefix| prefix.to_string())
}
pub async fn get_admin_password(&self) -> String {
let config = self.config.read().await;
config.password.clone()
}
pub async fn remove_prefix_from_path<'a>(&self, path: &'a str) -> &'a str {
let config = self.config.read().await;
if let Some(prefix) = &config.url_prefix {

View File

@@ -8,9 +8,7 @@ use axum::{
use serde::Deserialize;
use tracing::{error, info};
use crate::{
encryption::encrypt_password, server_storage::Server, url_helper::join_server_url, AppState,
};
use crate::{encryption::encrypt_password, server_storage::Server, AppState};
#[derive(Template)]
#[template(path = "admin/servers.html")]
@@ -48,24 +46,6 @@ pub struct AddServerAdminForm {
pub password: String,
}
#[derive(Deserialize)]
struct AuthResponse {
#[serde(rename = "User")]
user: JellyfinUser,
}
#[derive(Deserialize)]
struct JellyfinUser {
#[serde(rename = "Policy")]
policy: UserPolicy,
}
#[derive(Deserialize)]
struct UserPolicy {
#[serde(rename = "IsAdministrator")]
is_administrator: bool,
}
async fn render_server_list(state: &AppState) -> Result<String, String> {
match state.server_storage.list_servers().await {
Ok(servers) => {
@@ -275,44 +255,29 @@ pub async fn add_server_admin(
};
// 2. Verify credentials with upstream Jellyfin and check admin status
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
let client_info = crate::config::CLIENT_INFO.clone();
let auth_url = join_server_url(&server.url, "/Users/AuthenticateByName");
let body = serde_json::json!({
"Username": form.username,
"Pw": form.password
});
let auth_header = format!(
"MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\"",
env!("CARGO_PKG_VERSION")
);
let client = match jellyfin_api::JellyfinClient::new(server.url.as_str(), client_info) {
Ok(c) => c,
Err(e) => {
error!("Failed to create jellyfin client: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Client error</div>"),
)
.into_response();
}
};
match client
.post(auth_url.as_str())
.header("Authorization", auth_header)
.json(&body)
.send()
.authenticate_by_name(&form.username, &form.password)
.await
{
Ok(response) if response.status().is_success() => {
// Parse response to check if user is admin
let auth_response = match response.json::<AuthResponse>().await {
Ok(r) => r,
Err(e) => {
error!("Failed to parse auth response: {}", e);
return (
StatusCode::OK,
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Invalid server response</div>"),
)
.into_response();
}
};
Ok(user) => {
// Check if user is admin
let is_admin = user.policy.map(|p| p.is_administrator).unwrap_or(false);
if !auth_response.user.policy.is_administrator {
if !is_admin {
return (
StatusCode::OK,
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">User is not an administrator on this server</div>"),
@@ -361,24 +326,12 @@ pub async fn add_server_admin(
}
}
}
Ok(response) => {
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
(
StatusCode::OK,
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Invalid credentials</div>"),
)
.into_response()
} else {
(
StatusCode::OK,
Html(format!(
"<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Upstream error: {}</div>",
status
)),
)
.into_response()
}
Err(jellyfin_api::error::Error::AuthenticationFailed(_)) => {
(
StatusCode::OK,
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Invalid credentials</div>"),
)
.into_response()
}
Err(e) => {
error!("Failed to authenticate with upstream: {}", e);

View File

@@ -6,12 +6,13 @@ use axum::{
};
use tracing::error;
use crate::{url_helper::join_server_url, AppState};
use crate::AppState;
#[derive(Template)]
#[template(path = "admin/server_status.html")]
pub struct ServerStatusTemplate {
pub error_message: Option<String>,
pub server_version: Option<String>,
}
/// Check server status
@@ -22,31 +23,31 @@ pub async fn check_server_status(
// Get the server details first
match state.server_storage.get_server_by_id(server_id).await {
Ok(Some(server)) => {
// Test connection to the server
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
let client_info = crate::config::CLIENT_INFO.clone();
let status_url = join_server_url(&server.url, "/system/info/public");
match client.get(status_url.as_str()).send().await {
Ok(response) if response.status().is_success() => {
let client = match jellyfin_api::JellyfinClient::new(server.url.as_str(), client_info) {
Ok(c) => c,
Err(e) => {
let template = ServerStatusTemplate {
error_message: None,
error_message: Some(format!("Client error: {}", e)),
server_version: None,
};
match template.render() {
return match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render status template: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
}
}
};
}
Ok(response) => {
};
match client.get_public_system_info().await {
Ok(info) => {
let template = ServerStatusTemplate {
error_message: Some(format!("HTTP {}", response.status().as_u16())),
error_message: None,
server_version: info.version,
};
match template.render() {
@@ -58,16 +59,9 @@ pub async fn check_server_status(
}
}
Err(e) => {
let error_msg = if e.is_timeout() {
"Connection timeout".to_string()
} else if e.is_connect() {
"Connection refused".to_string()
} else {
format!("Network error: {e}")
};
let template = ServerStatusTemplate {
error_message: Some(error_msg),
error_message: Some(format!("Error: {}", e)),
server_version: None,
};
match template.render() {

View File

@@ -5,5 +5,8 @@
{% else %}
<span class="status-chip" style="background: rgba(40, 167, 69, 0.15); color: #28a745; font-weight: bold; padding: 0.25rem 0.75rem; border-radius: 1rem; font-size: 0.85rem; display: inline-flex; align-items: center; gap: 0.25rem;" title="Server is responding">
<i class="fas fa-check-circle"></i> Online
{% if let Some(ver) = server_version %}
<span style="font-weight: normal; opacity: 0.8; font-size: 0.75em;">({{ ver }})</span>
{% endif %}
</span>
{% endif %}

View File

@@ -1,15 +1,10 @@
{% if let Some(username) = username %}
<span style="color: var(--pico-primary-color);">
<i class="fas fa-check-circle" style="margin-right: 0.25rem;"></i>
{{ username }}
{{ username }} - ({{ server_version }})
</span>
{% else %}
{% if needs_login %}
<span style="color: #f39c12;" data-tooltip="Please login via the Web UI to sync your session">
<i class="fas fa-exclamation-triangle" style="margin-right: 0.25rem;"></i>
Online (Login required)
</span>
{% else if let Some(error) = error_message %}
{% if let Some(error) = error_message %}
<span style="color: var(--pico-del-color);">
<i class="fas fa-exclamation-circle" style="margin-right: 0.25rem;"></i>
{{ error }}

View File

@@ -4,38 +4,10 @@ use axum::{
http::StatusCode,
response::{Html, IntoResponse},
};
use serde::Deserialize;
use jellyfin_api::models::MediaFolder;
use tracing::error;
use crate::{ui::auth::AuthenticatedUser, url_helper::join_server_url, AppState};
#[derive(Deserialize, Clone)]
pub struct MediaFolder {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "CollectionType")]
pub collection_type: Option<String>,
}
#[derive(Deserialize)]
struct MediaFoldersResponse {
#[serde(rename = "Items")]
items: Vec<MediaFolder>,
}
#[derive(Deserialize)]
struct AuthResponse {
#[serde(rename = "AccessToken")]
access_token: String,
#[serde(rename = "User")]
user: JellyfinUser,
}
#[derive(Deserialize)]
struct JellyfinUser {
#[serde(rename = "Id")]
id: String,
}
use crate::{ui::auth::AuthenticatedUser, AppState};
pub struct ServerLibraries {
pub server_name: String,
@@ -62,10 +34,6 @@ pub async fn get_user_media(
};
let mut server_libraries: Vec<ServerLibraries> = Vec::new();
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
// Get all sessions for user once
let sessions = match state
@@ -90,125 +58,106 @@ pub async fn get_user_media(
.filter(|(_, s)| s.id == server.id)
.max_by_key(|(auth, _)| auth.updated_at);
let mut token = session.map(|(auth, _)| auth.jellyfin_token.clone());
let token = session.map(|(auth, _)| auth.jellyfin_token.clone());
let client_info = crate::config::CLIENT_INFO.clone();
let client = match jellyfin_api::JellyfinClient::new(server.url.as_str(), client_info) {
Ok(c) => c,
Err(e) => {
server_libraries.push(ServerLibraries {
server_name: server.name.clone(),
libraries: Vec::new(),
error: Some(format!("Client error: {}", e)),
});
continue;
}
};
// 1. Try to use existing token if available
if let Some(t) = &token {
let url = join_server_url(&server.url, "/Library/MediaFolders");
let auth_header = format!(
"MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\", Token=\"{}\"",
env!("CARGO_PKG_VERSION"),
t
);
let mut needs_reauth = true;
match client
.get(url.as_str())
.header("Authorization", auth_header)
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
match resp.json::<MediaFoldersResponse>().await {
Ok(folders) => {
libraries = folders.items;
}
Err(e) => {
error_msg = Some(format!("Failed to parse: {}", e));
}
}
if let Some(t) = &token {
let client_with_token = client.clone().with_token(t.clone());
match client_with_token.get_media_folders().await {
Ok(folders) => {
libraries = folders;
needs_reauth = false;
}
Ok(resp) if resp.status() == StatusCode::FORBIDDEN || resp.status() == StatusCode::UNAUTHORIZED => {
// Token expired, clear it to trigger re-login
token = None;
}
Ok(resp) => {
error_msg = Some(format!("HTTP {}", resp.status()));
Err(jellyfin_api::error::Error::AuthenticationFailed(_)) => {
// Token expired, needs reauth
needs_reauth = true;
}
Err(e) => {
error_msg = Some(format!("Network error: {}", e));
error_msg = Some(format!("Error: {}", e));
needs_reauth = false;
}
}
}
// 2. If no token or expired, try to login using mapping
if token.is_none() && (libraries.is_empty() && error_msg.is_none() || error_msg.as_deref() == Some("HTTP 401") || error_msg.as_deref() == Some("HTTP 403")) {
// Clear previous error if we are retrying
error_msg = None;
match state.user_authorization.get_server_mapping(&user.id, &server.url.as_str()).await {
if needs_reauth && error_msg.is_none() {
match state
.user_authorization
.get_server_mapping(&user.id, server.url.as_str())
.await
{
Ok(Some(mapping)) => {
// Decrypt password
let config = state.config.read().await;
let admin_password = &config.password;
let decrypted_password = state.user_authorization.decrypt_server_mapping_password(
&mapping,
&user.password,
admin_password
);
// Perform login
let auth_url = join_server_url(&server.url, "/Users/AuthenticateByName");
let body = serde_json::json!({
"Username": mapping.mapped_username,
"Pw": decrypted_password
});
let decrypted_password = state
.user_authorization
.decrypt_server_mapping_password(&mapping, &user.password, admin_password);
let auth_header = format!(
"MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\"",
env!("CARGO_PKG_VERSION")
);
match client
.authenticate_by_name(&mapping.mapped_username, &decrypted_password)
.await
{
Ok(user_info) => {
// Store new session
let auth = crate::models::Authorization {
client: "Jellyswarrm Proxy".to_string(),
device: "Server".to_string(),
device_id: "jellyswarrm-proxy".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
token: None,
};
match client.post(auth_url.as_str()).header("Authorization", auth_header).json(&body).send().await {
Ok(resp) if resp.status().is_success() => {
match resp.json::<AuthResponse>().await {
Ok(auth_resp) => {
// Store new session
let auth = crate::models::Authorization {
client: "Jellyswarrm Proxy".to_string(),
device: "Server".to_string(),
device_id: "jellyswarrm-proxy".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
token: None,
};
if let Err(e) = state.user_authorization.store_authorization_session(
if let Some(new_token) = client.get_token() {
if let Err(e) = state
.user_authorization
.store_authorization_session(
&user.id,
server.url.as_str(),
&auth,
auth_resp.access_token.clone(),
auth_resp.user.id,
None
).await {
error!("Failed to store session: {}", e);
new_token.to_string(),
user_info.id,
None,
)
.await
{
error!("Failed to store session: {}", e);
}
// Retry fetch libraries
match client.get_media_folders().await {
Ok(folders) => {
libraries = folders;
}
// Fetch libraries with new token
let url = join_server_url(&server.url, "/Library/MediaFolders");
let auth_header = format!(
"MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\", Token=\"{}\"",
env!("CARGO_PKG_VERSION"),
auth_resp.access_token
);
match client.get(url.as_str()).header("Authorization", auth_header).send().await {
Ok(resp) if resp.status().is_success() => {
match resp.json::<MediaFoldersResponse>().await {
Ok(folders) => {
libraries = folders.items;
}
Err(e) => error_msg = Some(format!("Failed to parse: {}", e)),
}
}
Ok(resp) => error_msg = Some(format!("HTTP {}", resp.status())),
Err(e) => error_msg = Some(format!("Network error: {}", e)),
Err(e) => {
error_msg = Some(format!(
"Error fetching libraries after login: {}",
e
));
}
}
Err(e) => error_msg = Some(format!("Login response error: {}", e)),
}
}
Ok(resp) => error_msg = Some(format!("Login failed: HTTP {}", resp.status())),
Err(e) => error_msg = Some(format!("Login network error: {}", e)),
Err(e) => {
error_msg = Some(format!("Login failed: {}", e));
}
}
}
Ok(None) => {

View File

@@ -1,17 +1,15 @@
use askama::Template;
use axum::{
extract::{Path, State},
http::{HeaderValue, StatusCode},
response::{Html, IntoResponse},
Form,
};
use hyper::{header::HeaderValue, StatusCode};
use jellyfin_api::JellyfinClient;
use serde::Deserialize;
use tracing::{error, info};
use tracing::{error, info, warn};
use crate::{
models::Authorization, server_storage::Server, ui::auth::AuthenticatedUser,
url_helper::join_server_url, AppState,
};
use crate::{server_storage::Server, ui::auth::AuthenticatedUser, AppState};
#[derive(Template)]
#[template(path = "user/user_server_list.html")]
@@ -33,23 +31,7 @@ pub struct ConnectServerForm {
pub struct UserServerStatusTemplate {
pub username: Option<String>,
pub error_message: Option<String>,
pub needs_login: bool,
}
#[derive(Deserialize)]
struct AuthResponse {
#[serde(rename = "AccessToken")]
access_token: String,
#[serde(rename = "User")]
user: JellyfinUser,
}
#[derive(Deserialize)]
struct JellyfinUser {
#[serde(rename = "Id")]
id: String,
#[serde(rename = "Name")]
name: String,
pub server_version: String,
}
pub async fn get_user_servers(
@@ -120,43 +102,24 @@ pub async fn connect_server(
};
// Verify credentials with upstream Jellyfin
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
let server_url = server.url.clone();
let auth_url = join_server_url(&server.url, "/Users/AuthenticateByName");
let body = serde_json::json!({
"Username": form.username,
"Pw": form.password
});
let client_info = crate::config::CLIENT_INFO.clone();
let auth_header = format!(
"MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\"",
env!("CARGO_PKG_VERSION")
);
match client
.post(auth_url.as_str())
.header("Authorization", auth_header)
.json(&body)
.send()
.await
{
Ok(response) if response.status().is_success() => {
// Parse response to get token and user ID
let auth_response = match response.json::<AuthResponse>().await {
Ok(r) => r,
Err(e) => {
error!("Failed to parse auth response: {}", e);
return (
StatusCode::OK,
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Invalid server response</div>"),
)
.into_response();
}
};
let client = match JellyfinClient::new(server_url.as_str(), client_info) {
Ok(c) => c,
Err(e) => {
error!("Failed to create jellyfin client: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Html("<span style=\"color: #dc3545;\">Client error</span>"),
)
.into_response();
}
};
match client.authenticate_by_name(&form.username, &form.password).await {
Ok(_) => {
// Credentials valid, create mapping
match state
.user_authorization
@@ -165,7 +128,7 @@ pub async fn connect_server(
server.url.as_str(),
&form.username,
&form.password,
None,
Some(&user.password),
)
.await
{
@@ -175,31 +138,6 @@ pub async fn connect_server(
user.username, server.name
);
// Create authorization session
let auth = Authorization {
client: "Jellyswarrm Proxy".to_string(),
device: "Server".to_string(),
device_id: "jellyswarrm-proxy".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
token: None,
};
if let Err(e) = state
.user_authorization
.store_authorization_session(
&user.id,
server.url.as_str(),
&auth,
auth_response.access_token,
auth_response.user.id,
None,
)
.await
{
error!("Failed to store session: {}", e);
// Continue anyway, as mapping was created
}
// Return HX-Redirect header for HTMX
let mut response = StatusCode::OK.into_response();
response.headers_mut().insert(
@@ -207,32 +145,23 @@ pub async fn connect_server(
HeaderValue::from_str(&format!("/{}", state.get_ui_route().await)).unwrap(),
);
response
}
Err(e) => {
error!("Failed to create mapping: {}", e);
(
StatusCode::OK,
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Database error</div>"),
)
.into_response()
}
},
Err(e) => {
error!("Failed to create mapping: {}", e);
(
StatusCode::OK,
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Database error</div>"),
)
.into_response()
}
}
}
Ok(response) => {
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
(
StatusCode::OK,
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Invalid credentials</div>"),
)
.into_response()
} else {
(
StatusCode::OK,
Html(format!("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Upstream error: {}</div>", status)),
)
.into_response()
}
Err(jellyfin_api::error::Error::AuthenticationFailed(_)) => {
(
StatusCode::OK,
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Invalid credentials</div>"),
)
.into_response()
}
Err(e) => {
error!("Failed to authenticate with upstream: {}", e);
@@ -333,15 +262,45 @@ pub async fn check_user_server_status(
}
};
// Check for existing session
let sessions = match state
// Always check public system info first to get version and name
let server_url = server.url.clone();
let client_info = crate::config::CLIENT_INFO.clone();
let (public_info, client) = match JellyfinClient::new(server_url.as_str(), client_info) {
Ok(c) => match c.get_public_system_info().await {
Ok(info) => (info, c),
Err(_) => {
return (
StatusCode::OK,
Html("<span style=\"color: #dc3545;\">Server offline or unreachable</span>"),
)
.into_response()
}
},
Err(e) => {
error!("Failed to create jellyfin client: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Html("<span style=\"color: #dc3545;\">Client error</span>"),
)
.into_response();
}
};
// Check for mapping and try to authenticate
let mapping = match state
.user_authorization
.get_user_sessions(&user.id, None)
.get_server_mapping(&user.id, server.url.as_str())
.await
{
Ok(s) => s,
Ok(Some(m)) => m,
Ok(None) => return (
StatusCode::OK,
Html("<span style=\"color: #dc3545;\">No mapping found for user on this server</span>"),
)
.into_response(),
Err(e) => {
error!("Failed to get sessions: {}", e);
error!("Failed to get server mapping: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Html("<span style=\"color: #dc3545;\">Database error</span>"),
@@ -350,80 +309,51 @@ pub async fn check_user_server_status(
}
};
// Find session for this server
let session = sessions
.iter()
.filter(|(_, s)| s.id == server.id)
.max_by_key(|(auth, _)| auth.updated_at);
let admin_password = state.get_admin_password().await;
if let Some((auth, _)) = session {
// Try to get profile with token
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
let password = state.user_authorization.decrypt_server_mapping_password(
&mapping,
&user.password,
&admin_password,
);
let profile_url = join_server_url(&server.url, "/Users/Me");
let auth_header = format!(
"MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\", Token=\"{}\"",
env!("CARGO_PKG_VERSION"),
auth.jellyfin_token
);
match client
.get(profile_url.as_str())
.header("Authorization", auth_header)
.send()
.await
{
Ok(response) if response.status().is_success() => {
match response.json::<JellyfinUser>().await {
Ok(profile) => {
let template = UserServerStatusTemplate {
username: Some(profile.name),
error_message: None,
needs_login: false,
};
match template.render() {
Ok(html) => return Html(html).into_response(),
Err(e) => error!("Template error: {}", e),
}
}
Err(e) => error!("Failed to parse profile: {}", e),
match client
.authenticate_by_name(&mapping.mapped_username, &password)
.await
{
Ok(jellyfin_user) => {
let template = UserServerStatusTemplate {
username: Some(jellyfin_user.name),
error_message: None,
server_version: public_info.version.unwrap_or("unknown".to_string()),
};
if let Err(e) = client.logout().await {
warn!("Failed to logout from upstream server: {}", e);
}
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render user server status template: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Html("<span style=\"color: #dc3545;\">Template error</span>"),
)
.into_response()
}
}
_ => {
// Token might be expired or invalid, fall through to public check
}
}
}
// Fallback: Check if server is online (public info)
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
let status_url = join_server_url(&server.url, "/system/info/public");
let (msg, needs_login) = match client.get(status_url.as_str()).send().await {
Ok(resp) if resp.status().is_success() => ("Online".to_string(), true),
Ok(resp) => (format!("HTTP {}", resp.status().as_u16()), false),
Err(_) => ("Offline".to_string(), false),
};
let template = UserServerStatusTemplate {
username: None,
error_message: Some(msg),
needs_login,
};
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Template error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
// Auth failed, log it but continue to check existing session
tracing::warn!(
"Failed to authenticate with mapped credentials for server {}: {}",
server.id,
e
);
(
StatusCode::UNAUTHORIZED,
Html("<span style=\"color: #dc3545;\">Failed to log in with provided credentials</span>"),
)
.into_response()
}
}
}