mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-28 17:52:50 +01:00
feat: add Plex media-server catalog and playback baseline (#748)
* feat: add Plex media-server catalog baseline * feat: add Plex media-server playback baseline
This commit is contained in:
Generated
+75
-41
@@ -16,7 +16,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -140,9 +140,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.0"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6"
|
||||
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
@@ -395,16 +395,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.3"
|
||||
version = "1.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d"
|
||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -687,6 +687,15 @@ 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 = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -800,7 +809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
@@ -1181,11 +1190,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1277,7 +1286,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"cron",
|
||||
"enum-iterator",
|
||||
"fastrand",
|
||||
"futures",
|
||||
"futures-signals",
|
||||
@@ -1293,6 +1301,8 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shared",
|
||||
"strum",
|
||||
"strum_macros",
|
||||
"thiserror 2.0.18",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
@@ -1742,6 +1752,16 @@ dependencies = [
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "granit-parser"
|
||||
version = "0.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7e736dfe3881c53a7dce0685eb18202d0d9fe6911782f9870946eb9ee89d778"
|
||||
dependencies = [
|
||||
"arraydeque",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
@@ -1827,6 +1847,12 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -2172,12 +2198,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
@@ -2456,9 +2482,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.13.0"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db9a0d582c2874f68138a16ce1867e0ffde6c0bb0a0df85e1f36d04146db488a"
|
||||
checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
|
||||
dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
@@ -2528,9 +2554,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -3753,17 +3779,6 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "saphyr-parser-bw"
|
||||
version = "0.0.610"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d643f5e972f17219245b82f038c22cd3c74320bb17c6e8f7e8537de268b1bc6"
|
||||
dependencies = [
|
||||
"arraydeque",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
@@ -3853,19 +3868,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde-saphyr"
|
||||
version = "0.0.22"
|
||||
version = "0.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "546b4da4f679832602a8f8ab8ddc10b6b1d2e1a13b4f9dddcaee499436fa06ad"
|
||||
checksum = "dcc7fe48e34d02a97bc8e6253b8b91e5a47fe2c47eaacb5149cefbb69922eaf0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"annotate-snippets",
|
||||
"base64",
|
||||
"encoding_rs_io",
|
||||
"getrandom 0.3.4",
|
||||
"granit-parser",
|
||||
"nohash-hasher",
|
||||
"num-traits",
|
||||
"regex",
|
||||
"saphyr-parser-bw",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"zmij",
|
||||
@@ -3989,7 +4003,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -4000,7 +4014,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -4017,7 +4031,6 @@ dependencies = [
|
||||
"ciborium",
|
||||
"dashmap",
|
||||
"deunicode",
|
||||
"enum-iterator",
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"hex",
|
||||
@@ -4034,6 +4047,8 @@ dependencies = [
|
||||
"serde-saphyr",
|
||||
"serde_json",
|
||||
"serde_tuple",
|
||||
"strum",
|
||||
"strum_macros",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"zeroize",
|
||||
@@ -4148,6 +4163,24 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -4343,9 +4376,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.50.0"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -4360,9 +4393,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.1"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4615,7 +4648,6 @@ dependencies = [
|
||||
"ctr",
|
||||
"dashmap",
|
||||
"deunicode",
|
||||
"enum-iterator",
|
||||
"env_logger",
|
||||
"fancy-regex",
|
||||
"fastrand",
|
||||
@@ -4665,6 +4697,8 @@ dependencies = [
|
||||
"smallvec",
|
||||
"socket2",
|
||||
"strsim",
|
||||
"strum",
|
||||
"strum_macros",
|
||||
"sysinfo",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
|
||||
+9
-8
@@ -15,13 +15,13 @@ log = "0.4.29"
|
||||
chrono = "0.4.44"
|
||||
bytes = "1.11.1"
|
||||
base64 = "0.22.1"
|
||||
blake3 = "1.8.3"
|
||||
fastrand = "2.3.0"
|
||||
lz4_flex = "0.13.0"
|
||||
blake3 = "1.8.5"
|
||||
fastrand = "2.4.1"
|
||||
lz4_flex = "0.13.1"
|
||||
brotli = "8.0.2"
|
||||
indexmap = "2.13.0"
|
||||
indexmap = "2.14.0"
|
||||
dashmap = "6.1.0"
|
||||
arc-swap = "1.9.0"
|
||||
arc-swap = "1.9.1"
|
||||
paste = "1.0.15"
|
||||
pest = "2.8.6"
|
||||
pest_derive = "2.8.6"
|
||||
@@ -30,11 +30,12 @@ deunicode = "1.6.2"
|
||||
path-clean = "1.0.1"
|
||||
url = "2.5.8"
|
||||
cron = "0.16.0"
|
||||
enum-iterator = "2.3.0"
|
||||
futures = "0.3.32"
|
||||
serde-saphyr = "0.0.22"
|
||||
serde-saphyr = "0.0.26"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.50.0" }
|
||||
tokio = { version = "1.52.3" }
|
||||
strum = "0.28.0"
|
||||
strum_macros = "0.28.0"
|
||||
|
||||
[profile.release]
|
||||
debug = false
|
||||
|
||||
+3
-1
@@ -32,7 +32,6 @@ futures.workspace = true
|
||||
path-clean.workspace = true
|
||||
pest.workspace = true
|
||||
pest_derive.workspace = true
|
||||
enum-iterator.workspace = true
|
||||
aes = "0.8.4"
|
||||
ctr = "0.9.2"
|
||||
deunicode.workspace = true
|
||||
@@ -83,6 +82,9 @@ memchr = "2.8.0"
|
||||
http-body-util = "0.1.3"
|
||||
lru = "0.12"
|
||||
ipnet = "2"
|
||||
strum.workspace = true
|
||||
strum_macros.workspace = true
|
||||
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -4,12 +4,21 @@ use crate::{
|
||||
model::{
|
||||
create_active_client_stream, create_channel_unavailable_stream, create_custom_video_stream_response,
|
||||
create_provider_connections_exhausted_stream, create_provider_stream, get_stream_response_with_headers,
|
||||
tee_stream, AppState, CustomVideoStreamType, ProviderAllocation, ProviderConfig,
|
||||
ProviderStreamFactoryOptions, ProviderStreamState, SharedStreamManager, StreamDetails, StreamError,
|
||||
tee_stream, AppState, BoxedProviderStream, CustomVideoStreamType, ProviderAllocation, ProviderConfig,
|
||||
ProviderStreamFactoryOptions, ProviderStreamInfo, ProviderStreamState, SharedStreamManager, StreamDetails, StreamError,
|
||||
StreamingStrategy, ThrottledStream, UserApiRequest, UserSession, PendingProviderReason,
|
||||
},
|
||||
},
|
||||
auth::Fingerprint,
|
||||
media_server::{
|
||||
playback::{
|
||||
media_server_image_response as open_media_server_proxy_image_response,
|
||||
media_server_stream_response as open_media_server_proxy_stream_response,
|
||||
parse_media_server_image_ref, parse_media_server_stream_ref,
|
||||
},
|
||||
plex::client::PlexCatalogClient,
|
||||
MediaServerError, MediaServerErrorKind, MediaServerHttpClient, MediaServerImageRef,
|
||||
},
|
||||
model::{ConfigInput, ConfigTarget, ProxyUserCredentials},
|
||||
utils::{
|
||||
async_file_reader, async_file_writer, create_new_file_for_write, debug_if_enabled, get_file_extension, request,
|
||||
@@ -21,7 +30,7 @@ use crate::{
|
||||
use arc_swap::ArcSwapOption;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{header, Extensions, HeaderMap, HeaderValue, Response, StatusCode},
|
||||
http::{header, Extensions, HeaderMap, HeaderName, HeaderValue, Response, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
@@ -33,7 +42,7 @@ use serde::Serialize;
|
||||
use shared::{
|
||||
concat_string,
|
||||
model::{
|
||||
Claims, InputFetchMethod, PlaylistEntry, PlaylistItemType, ProxyType, StreamChannel, StreamInfo, TargetType,
|
||||
Claims, InputFetchMethod, InputType, PlaylistEntry, PlaylistItemType, ProxyType, StreamChannel, StreamInfo, TargetType,
|
||||
UserConnectionPermission, VirtualId, XtreamCluster,
|
||||
},
|
||||
utils::{
|
||||
@@ -247,6 +256,9 @@ async fn get_admission_for_request(
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_request_url_for_logging<'a>(input: &ConfigInput, stream_url: &'a str) -> Cow<'a, str> {
|
||||
if is_media_server_playback_url(input, stream_url) {
|
||||
return Cow::Borrowed("media-server://<redacted>");
|
||||
}
|
||||
if is_sanitize_sensitive_info_enabled() {
|
||||
return Cow::Borrowed(stream_url);
|
||||
}
|
||||
@@ -1648,6 +1660,14 @@ async fn create_stream_response_details(
|
||||
sanitize_sensitive_info(resolve_request_url_for_logging(input, request_url.as_ref()).as_ref())
|
||||
);
|
||||
(None, None, None)
|
||||
} else if is_media_server_stream_ref_url(request_url.as_ref()) {
|
||||
match open_media_server_stream_for_input(app_state, input, request_url.as_ref(), req_headers).await {
|
||||
Ok((stream, stream_info)) => (Some(stream), stream_info, None),
|
||||
Err(err) => {
|
||||
error!("Can't open media-server stream: {err}");
|
||||
(None, None, None)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let parsed_url = Url::parse(&request_url);
|
||||
let ((stream, stream_info), reconnect_flag) = if let Ok(url) = parsed_url {
|
||||
@@ -1771,6 +1791,9 @@ where
|
||||
{
|
||||
let item_type = params.item.get_item_type();
|
||||
let provider_url = params.item.get_provider_url();
|
||||
if is_media_server_playback_url(params.input, provider_url.as_ref()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let redirect_request = params.user.proxy.is_redirect(item_type) || params.target.is_force_redirect(item_type);
|
||||
let is_hls_request = item_type == PlaylistItemType::LiveHls || params.stream_ext == Some(HLS_EXT);
|
||||
@@ -1856,6 +1879,14 @@ where
|
||||
None
|
||||
}
|
||||
|
||||
fn is_media_server_playback_url(input: &ConfigInput, stream_url: &str) -> bool {
|
||||
input.input_type == InputType::Plex || is_media_server_stream_ref_url(stream_url)
|
||||
}
|
||||
|
||||
fn is_media_server_stream_ref_url(stream_url: &str) -> bool {
|
||||
Url::parse(stream_url).is_ok_and(|url| url.scheme() == "media-server")
|
||||
}
|
||||
|
||||
fn is_throttled_stream(item_type: PlaylistItemType, throttle_kbps: usize) -> bool {
|
||||
throttle_kbps > 0
|
||||
&& matches!(
|
||||
@@ -1884,6 +1915,59 @@ where
|
||||
body_stream
|
||||
}
|
||||
|
||||
async fn open_media_server_stream_for_input(
|
||||
app_state: &Arc<AppState>,
|
||||
input: &ConfigInput,
|
||||
stream_url: &str,
|
||||
req_headers: &HeaderMap,
|
||||
) -> Result<(BoxedProviderStream, ProviderStreamInfo), MediaServerError> {
|
||||
let stream_ref = parse_media_server_stream_ref(&input.name, stream_url)?;
|
||||
let range = req_headers.get(header::RANGE).and_then(|value| value.to_str().ok());
|
||||
let http_client = MediaServerHttpClient::new(app_state.http_client.load().as_ref().clone());
|
||||
|
||||
let response = match input.input_type {
|
||||
InputType::Plex => {
|
||||
let client = PlexCatalogClient::from_input(input, http_client)?;
|
||||
open_media_server_proxy_stream_response(&client, &stream_ref, range).await?
|
||||
}
|
||||
InputType::Emby | InputType::Jellyfin => {
|
||||
return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed)
|
||||
.provider("media-server")
|
||||
.detail("media-server playback proxy is not implemented for this input type"));
|
||||
}
|
||||
InputType::M3u | InputType::Xtream | InputType::M3uBatch | InputType::XtreamBatch | InputType::Library => {
|
||||
return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed)
|
||||
.provider("media-server")
|
||||
.detail("playlist item is not backed by a media-server input"));
|
||||
}
|
||||
};
|
||||
|
||||
let headers = response
|
||||
.headers
|
||||
.iter()
|
||||
.filter(|(key, _)| !is_hop_by_hop_response_header(key))
|
||||
.filter_map(|(key, value)| value.to_str().ok().map(|value| (key.to_string(), value.to_string())))
|
||||
.collect::<Vec<_>>();
|
||||
let status = response.status;
|
||||
let stream = response.body.map_err(|err| StreamError::Stream(err.to_string())).boxed();
|
||||
Ok((stream, Some((headers, status, None, None))))
|
||||
}
|
||||
|
||||
fn is_hop_by_hop_response_header(name: &HeaderName) -> bool {
|
||||
matches!(
|
||||
name.as_str(),
|
||||
"connection"
|
||||
| "keep-alive"
|
||||
| "proxy-authenticate"
|
||||
| "proxy-authorization"
|
||||
| "te"
|
||||
| "trailer"
|
||||
| "trailers"
|
||||
| "transfer-encoding"
|
||||
| "upgrade"
|
||||
)
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn force_provider_stream_response(
|
||||
@@ -3070,6 +3154,20 @@ pub async fn resource_response(
|
||||
if resource_url.is_empty() {
|
||||
return StatusCode::NO_CONTENT.into_response();
|
||||
}
|
||||
if resource_url.starts_with("media-server://image/") {
|
||||
return match open_media_server_image_resource(app_state, resource_url).await {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
let status = media_server_image_error_status(&err);
|
||||
match status {
|
||||
StatusCode::BAD_REQUEST => warn!("Invalid media-server image resource URL: {err}"),
|
||||
StatusCode::NOT_FOUND => debug!("Media-server image resource was not found: {err}"),
|
||||
_ => error!("Can't open media-server image from upstream: {err}"),
|
||||
}
|
||||
status.into_response()
|
||||
}
|
||||
};
|
||||
}
|
||||
let filter: HeaderFilter = Some(Box::new(|key| key != "if-none-match" && key != "if-modified-since"));
|
||||
let req_headers = get_headers_from_request(req_headers, &filter);
|
||||
if let Some(cache) = app_state.cache.load().as_ref() {
|
||||
@@ -3097,6 +3195,88 @@ pub async fn resource_response(
|
||||
StatusCode::BAD_REQUEST.into_response()
|
||||
}
|
||||
|
||||
async fn open_media_server_image_resource(
|
||||
app_state: &Arc<AppState>,
|
||||
resource_url: &str,
|
||||
) -> Result<Response<Body>, MediaServerError> {
|
||||
let image_ref = parse_media_server_image_ref(resource_url)?;
|
||||
let input_name = media_server_image_input_name(&image_ref);
|
||||
let input = app_state.app_config.get_input_by_name(input_name).ok_or_else(|| {
|
||||
MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound)
|
||||
.provider("media-server")
|
||||
.detail("media-server image input was not found")
|
||||
})?;
|
||||
let http_client = MediaServerHttpClient::new(app_state.http_client.load().as_ref().clone());
|
||||
|
||||
let response = match input.input_type {
|
||||
InputType::Plex => {
|
||||
let client = PlexCatalogClient::from_input(&input, http_client)?;
|
||||
open_media_server_proxy_image_response(&client, &image_ref).await?
|
||||
}
|
||||
InputType::Emby | InputType::Jellyfin => {
|
||||
return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed)
|
||||
.provider("media-server")
|
||||
.detail("media-server image proxy is not implemented for this input type"));
|
||||
}
|
||||
InputType::M3u | InputType::Xtream | InputType::M3uBatch | InputType::XtreamBatch | InputType::Library => {
|
||||
return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed)
|
||||
.provider("media-server")
|
||||
.detail("media-server image input is not backed by a media-server input"));
|
||||
}
|
||||
};
|
||||
|
||||
let mut builder = Response::builder().status(response.status);
|
||||
for (key, value) in &response.headers {
|
||||
if !is_hop_by_hop_response_header(key) {
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
}
|
||||
let body = response.body.map_err(|err| StreamError::Stream(err.to_string()));
|
||||
builder.body(Body::from_stream(body)).map_err(|err| {
|
||||
MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed)
|
||||
.provider("media-server")
|
||||
.detail(format!("media-server image response build failed: {err}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn media_server_image_error_status(err: &MediaServerError) -> StatusCode {
|
||||
match err.kind {
|
||||
MediaServerErrorKind::MediaServerItemNotFound | MediaServerErrorKind::NoDirectPlayableMediaServerSource => {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
MediaServerErrorKind::MediaServerStreamOpenFailed if is_media_server_image_validation_error(err) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
MediaServerErrorKind::MediaServerStreamOpenFailed
|
||||
| MediaServerErrorKind::MediaServerAuthDenied
|
||||
| MediaServerErrorKind::MediaServerUnavailable
|
||||
| MediaServerErrorKind::MediaServerLibraryUnavailable
|
||||
| MediaServerErrorKind::MediaServerLibraryTypeUnsupported
|
||||
| MediaServerErrorKind::MediaServerCatalogDecodeFailed
|
||||
| MediaServerErrorKind::MediaServerCatalogPageStalled
|
||||
| MediaServerErrorKind::MediaServerCatalogIncomplete
|
||||
| MediaServerErrorKind::MediaServerRateLimited
|
||||
| MediaServerErrorKind::MediaServerDiscoveryFailed => StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_media_server_image_validation_error(err: &MediaServerError) -> bool {
|
||||
err.detail_text().is_some_and(|detail| {
|
||||
detail.contains("resource URL is not a media server image URL")
|
||||
|| detail.contains("media server image URL is missing required path parts")
|
||||
|| detail.contains("unsupported media server image URL scheme")
|
||||
|| detail.contains("media-server image input is not backed by a media-server input")
|
||||
})
|
||||
}
|
||||
|
||||
fn media_server_image_input_name(image_ref: &MediaServerImageRef) -> &Arc<str> {
|
||||
match image_ref {
|
||||
MediaServerImageRef::Emby { input_name, .. }
|
||||
| MediaServerImageRef::Jellyfin { input_name, .. }
|
||||
| MediaServerImageRef::Plex { input_name, .. } => input_name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn separate_number_and_remainder(input: &str) -> (&str, Option<&str>) {
|
||||
input.rfind('.').map_or_else(
|
||||
|| (input, None),
|
||||
@@ -3486,6 +3666,79 @@ mod tests {
|
||||
assert_eq!(resolved, "https://provider.example/live/provider-user/provider-pass/33486.m3u8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_server_proxy_response_header_filter_drops_hop_by_hop_headers() {
|
||||
for name in [
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
] {
|
||||
assert!(is_hop_by_hop_response_header(&HeaderName::from_static(name)));
|
||||
}
|
||||
assert!(!is_hop_by_hop_response_header(&header::CONTENT_TYPE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_server_image_error_status_classifies_client_and_upstream_failures() {
|
||||
let parse_error = MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed)
|
||||
.detail("media server image URL is missing required path parts");
|
||||
assert_eq!(media_server_image_error_status(&parse_error), StatusCode::BAD_REQUEST);
|
||||
|
||||
let not_found = MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound)
|
||||
.detail("plex media-server image URL is missing image_path");
|
||||
assert_eq!(media_server_image_error_status(¬_found), StatusCode::NOT_FOUND);
|
||||
|
||||
let upstream = MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed)
|
||||
.detail("media-server image request failed");
|
||||
assert_eq!(media_server_image_error_status(&upstream), StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_server_playback_urls_are_proxy_only_redirect_guard_candidates() {
|
||||
let plex_input = ConfigInput {
|
||||
input_type: InputType::Plex,
|
||||
..ConfigInput::default()
|
||||
};
|
||||
let emby_input = ConfigInput {
|
||||
input_type: InputType::Emby,
|
||||
..ConfigInput::default()
|
||||
};
|
||||
let m3u_input = ConfigInput {
|
||||
input_type: InputType::M3u,
|
||||
..ConfigInput::default()
|
||||
};
|
||||
|
||||
assert!(is_media_server_playback_url(
|
||||
&plex_input,
|
||||
"media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted"
|
||||
));
|
||||
assert!(is_media_server_playback_url(
|
||||
&m3u_input,
|
||||
"media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted"
|
||||
));
|
||||
assert!(is_media_server_playback_url(&plex_input, "https://plex.example/stream.mkv"));
|
||||
assert!(!is_media_server_playback_url(&emby_input, "https://emby.example/stream.mkv"));
|
||||
assert!(!is_media_server_playback_url(&m3u_input, "https://provider.example/stream.mkv"));
|
||||
assert!(!is_media_server_stream_ref_url("https://provider.example/stream.mkv"));
|
||||
assert!(is_media_server_stream_ref_url(
|
||||
"media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted"
|
||||
));
|
||||
assert_eq!(
|
||||
resolve_request_url_for_logging(
|
||||
&plex_input,
|
||||
"media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted"
|
||||
)
|
||||
.as_ref(),
|
||||
"media-server://<redacted>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_streaming_response_extension_disables_compression() {
|
||||
let mut response = Response::new(());
|
||||
|
||||
@@ -169,6 +169,10 @@ pub(in crate::api) fn get_xtream_player_api_stream_url(
|
||||
action_path: &str,
|
||||
fallback_url: &Arc<str>,
|
||||
) -> Option<Arc<str>> {
|
||||
if input.input_type.is_media_server() {
|
||||
return (!fallback_url.is_empty()).then(|| fallback_url.clone());
|
||||
}
|
||||
|
||||
if let Some(input_user_info) = input.get_user_info() {
|
||||
let ctx = match context {
|
||||
ApiStreamContext::LiveAlt | ApiStreamContext::Live => {
|
||||
@@ -1601,10 +1605,13 @@ pub fn xtream_api_register() -> axum::Router<Arc<AppState>> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{empty_stream_info_response, resolve_xtream_playback_extension, XtreamApiTimeShiftRequest};
|
||||
use crate::api::model::UserApiRequest;
|
||||
use super::{
|
||||
empty_stream_info_response, get_xtream_player_api_stream_url, resolve_xtream_playback_extension,
|
||||
ApiStreamContext, XtreamApiTimeShiftRequest,
|
||||
};
|
||||
use crate::{api::model::UserApiRequest, model::ConfigInput};
|
||||
use shared::{
|
||||
model::{PlaylistItemType, StreamProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem},
|
||||
model::{InputType, PlaylistItemType, StreamProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem},
|
||||
utils::Internable,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
@@ -1828,6 +1835,21 @@ mod tests {
|
||||
assert_eq!(resolve_xtream_playback_extension(Some(".mkv"), &pli).as_deref(), Some(".mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_server_xtream_playback_uses_internal_stream_ref_even_with_direct_pms_url_credentials() {
|
||||
let input = ConfigInput {
|
||||
input_type: InputType::Plex,
|
||||
url: "http://pms-user:pms-pass@pms.example.invalid:32400".to_string(),
|
||||
..ConfigInput::default()
|
||||
};
|
||||
let fallback = Arc::<str>::from("media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted%2Ffile.mkv");
|
||||
|
||||
let resolved = get_xtream_player_api_stream_url(&input, ApiStreamContext::Movie, "813563.mkv", &fallback)
|
||||
.expect("media-server fallback should be preserved");
|
||||
|
||||
assert_eq!(resolved, fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_playback_extension_keeps_client_requested_extension() {
|
||||
let mut pli = create_test_vod_item("provider://strong/live/user/pass/813563.ts", "ts", PlaylistItemType::Live);
|
||||
|
||||
@@ -500,7 +500,7 @@ async fn persist_yaml_file<T: serde::Serialize>(
|
||||
payload: &T,
|
||||
) -> Result<(), SetupPersistWriteError> {
|
||||
let mut content = String::new();
|
||||
let options = serde_saphyr::SerializerOptions { prefer_block_scalars: false, ..Default::default() };
|
||||
let options = serde_saphyr::ser_options! {prefer_block_scalars: false};
|
||||
serde_saphyr::to_fmt_writer_with_options(&mut content, payload, options)
|
||||
.map_err(|err| SetupPersistWriteError::Serialize(err.to_string()))?;
|
||||
tokio::fs::write(file_path, content).await.map_err(SetupPersistWriteError::Io)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use shared::model::MediaServerLibraryKind;
|
||||
use crate::media_server::{
|
||||
MediaServerCatalogClient, MediaServerEpisode, MediaServerError, MediaServerErrorKind, MediaServerLibrary,
|
||||
MediaServerLibraryKind, MediaServerMovie, MediaServerPage, MediaServerPageRequest, MediaServerSeason,
|
||||
MediaServerMovie, MediaServerPage, MediaServerPageRequest, MediaServerSeason,
|
||||
MediaServerSeries,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MediaServerCatalogCursor {
|
||||
@@ -34,10 +36,11 @@ impl MediaServerCatalogCursor {
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub struct MediaServerCatalogRefreshPolicy {
|
||||
pub page_size: usize,
|
||||
pub request_delay_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for MediaServerCatalogRefreshPolicy {
|
||||
fn default() -> Self { Self { page_size: 100 } }
|
||||
fn default() -> Self { Self { page_size: 100, request_delay_ms: 0 } }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
@@ -118,6 +121,7 @@ where
|
||||
let next_request = page.next_request();
|
||||
snapshot.movies.extend(page.items);
|
||||
let Some(next) = next_request else { break };
|
||||
wait_before_next_catalog_page(policy).await;
|
||||
page_request = next;
|
||||
}
|
||||
}
|
||||
@@ -129,6 +133,7 @@ where
|
||||
let next_request = page.next_request();
|
||||
snapshot.series.extend(page.items);
|
||||
let Some(next) = next_request else { break };
|
||||
wait_before_next_catalog_page(policy).await;
|
||||
page_request = next;
|
||||
}
|
||||
|
||||
@@ -139,6 +144,7 @@ where
|
||||
let next_request = page.next_request();
|
||||
snapshot.seasons.extend(page.items);
|
||||
let Some(next) = next_request else { break };
|
||||
wait_before_next_catalog_page(policy).await;
|
||||
page_request = next;
|
||||
}
|
||||
|
||||
@@ -149,6 +155,7 @@ where
|
||||
let next_request = page.next_request();
|
||||
snapshot.episodes.extend(page.items);
|
||||
let Some(next) = next_request else { break };
|
||||
wait_before_next_catalog_page(policy).await;
|
||||
page_request = next;
|
||||
}
|
||||
}
|
||||
@@ -159,6 +166,12 @@ where
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
async fn wait_before_next_catalog_page(policy: MediaServerCatalogRefreshPolicy) {
|
||||
if policy.request_delay_ms > 0 {
|
||||
tokio::time::sleep(Duration::from_millis(policy.request_delay_ms)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_page_progress<T>(library: &MediaServerLibrary, page: &MediaServerPage<T>) -> Result<(), MediaServerError> {
|
||||
let cursor = MediaServerCatalogCursor::from_page(library, page);
|
||||
if cursor.is_stalled_before_end() {
|
||||
@@ -181,11 +194,15 @@ mod tests {
|
||||
use bytes::Bytes;
|
||||
use futures::{stream, StreamExt};
|
||||
use reqwest::{header::HeaderMap, StatusCode};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct MockMediaServerCatalogClient {
|
||||
movie_pages: Mutex<Vec<Result<MediaServerPage<MediaServerMovie>, MediaServerError>>>,
|
||||
movie_call_times: Mutex<Vec<Instant>>,
|
||||
series_pages: Mutex<Vec<Result<MediaServerPage<MediaServerSeries>, MediaServerError>>>,
|
||||
season_pages: Mutex<Vec<Result<MediaServerPage<MediaServerSeason>, MediaServerError>>>,
|
||||
episode_pages: Mutex<Vec<Result<MediaServerPage<MediaServerEpisode>, MediaServerError>>>,
|
||||
@@ -216,6 +233,7 @@ mod tests {
|
||||
_library: &MediaServerLibraryRef,
|
||||
_page: MediaServerPageRequest,
|
||||
) -> Result<MediaServerPage<MediaServerMovie>, MediaServerError> {
|
||||
self.movie_call_times.lock().expect("lock").push(Instant::now());
|
||||
self.movie_pages.lock().expect("lock").remove(0)
|
||||
}
|
||||
|
||||
@@ -395,13 +413,34 @@ mod tests {
|
||||
]);
|
||||
|
||||
let outcome = cache
|
||||
.refresh_or_retain(&client, MediaServerCatalogRefreshPolicy { page_size: 1 })
|
||||
.refresh_or_retain(&client, MediaServerCatalogRefreshPolicy { page_size: 1, ..MediaServerCatalogRefreshPolicy::default() })
|
||||
.await;
|
||||
|
||||
assert!(matches!(outcome, MediaServerCatalogRefreshOutcome::Retained { retained: true, .. }));
|
||||
assert_eq!(cache.trusted().expect("previous snapshot retained").movies[0].item_id.as_ref(), "old");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_waits_configured_delay_between_catalog_pages() {
|
||||
let client = MockMediaServerCatalogClient::with_libraries(vec![movie_library()]);
|
||||
client.movie_pages.lock().expect("lock").extend([
|
||||
Ok(MediaServerPage::new(MediaServerPageRequest::new(0, 1), Some(2), vec![movie("movie-1")])),
|
||||
Ok(MediaServerPage::new(MediaServerPageRequest::new(1, 1), Some(2), vec![movie("movie-2")])),
|
||||
]);
|
||||
|
||||
let snapshot = refresh_media_server_catalog_complete_before_publish(
|
||||
&client,
|
||||
MediaServerCatalogRefreshPolicy { page_size: 1, request_delay_ms: 20 },
|
||||
)
|
||||
.await
|
||||
.expect("catalog refresh succeeds");
|
||||
|
||||
let call_times = client.movie_call_times.lock().expect("lock");
|
||||
assert_eq!(snapshot.movies.len(), 2);
|
||||
assert_eq!(call_times.len(), 2);
|
||||
assert!(call_times[1].duration_since(call_times[0]).as_millis() >= 15);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stalled_page_returns_stable_failure() {
|
||||
let client = MockMediaServerCatalogClient::with_libraries(vec![movie_library()]);
|
||||
|
||||
@@ -110,9 +110,14 @@ impl MediaServerHttpRequestBuilder {
|
||||
}
|
||||
|
||||
pub async fn send(self) -> Result<reqwest::Response, MediaServerError> {
|
||||
let detail = format!("request {} failed", self.safe_url);
|
||||
self.send_with_error_detail(detail).await
|
||||
}
|
||||
|
||||
pub async fn send_with_error_detail(self, detail: impl Into<String>) -> Result<reqwest::Response, MediaServerError> {
|
||||
let detail = detail.into();
|
||||
self.builder.send().await.map_err(|err| {
|
||||
MediaServerError::from_reqwest_error_with_fallback(&err, self.not_found_kind, self.fallback_kind)
|
||||
.detail(format!("request {} failed", self.safe_url))
|
||||
MediaServerError::from_reqwest_error_with_fallback(&err, self.not_found_kind, self.fallback_kind).detail(&detail)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,59 @@ pub fn parse_media_server_stream_ref(input_name: &Arc<str>, item_url: &str) -> R
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_media_server_image_ref(resource_url: &str) -> Result<MediaServerImageRef, MediaServerError> {
|
||||
let Some(rest) = resource_url.strip_prefix("media-server://image/") else {
|
||||
return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed)
|
||||
.detail("resource URL is not a media server image URL"));
|
||||
};
|
||||
let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
|
||||
let parts: Vec<String> = path.split('/').map(unescape_internal_url_component).collect();
|
||||
if parts.len() < 4 {
|
||||
return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed)
|
||||
.detail("media server image URL is missing required path parts"));
|
||||
}
|
||||
|
||||
match parts[0].as_str() {
|
||||
"emby" => Ok(MediaServerImageRef::Emby {
|
||||
input_name: Arc::<str>::from(parts[1].as_str()),
|
||||
server_id: Arc::<str>::from(parts[2].as_str()),
|
||||
item_id: Arc::<str>::from(parts[3].as_str()),
|
||||
image_kind: query_value(query, "image_kind")
|
||||
.map(Arc::<str>::from)
|
||||
.ok_or_else(|| {
|
||||
MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound)
|
||||
.detail("emby media-server image URL is missing image_kind")
|
||||
})?,
|
||||
tag: query_value(query, "tag").map(Arc::<str>::from),
|
||||
}),
|
||||
"jellyfin" => Ok(MediaServerImageRef::Jellyfin {
|
||||
input_name: Arc::<str>::from(parts[1].as_str()),
|
||||
server_id: Arc::<str>::from(parts[2].as_str()),
|
||||
item_id: Arc::<str>::from(parts[3].as_str()),
|
||||
image_kind: query_value(query, "image_kind")
|
||||
.map(Arc::<str>::from)
|
||||
.ok_or_else(|| {
|
||||
MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound)
|
||||
.detail("jellyfin media-server image URL is missing image_kind")
|
||||
})?,
|
||||
tag: query_value(query, "tag").map(Arc::<str>::from),
|
||||
}),
|
||||
"plex" => Ok(MediaServerImageRef::Plex {
|
||||
input_name: Arc::<str>::from(parts[1].as_str()),
|
||||
server_id: Arc::<str>::from(parts[2].as_str()),
|
||||
rating_key: Arc::<str>::from(parts[3].as_str()),
|
||||
image_path: query_value(query, "image_path")
|
||||
.map(Arc::<str>::from)
|
||||
.ok_or_else(|| {
|
||||
MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound)
|
||||
.detail("plex media-server image URL is missing image_path")
|
||||
})?,
|
||||
}),
|
||||
_ => Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed)
|
||||
.detail("unsupported media server image URL scheme")),
|
||||
}
|
||||
}
|
||||
|
||||
fn query_value(query: &str, key: &str) -> Option<String> {
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.find_map(|(name, value)| (name == key).then(|| value.into_owned()))
|
||||
@@ -191,8 +244,9 @@ fn hex_value(value: u8) -> Option<u8> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::media_server::{
|
||||
MediaServerEpisode, MediaServerLibrary, MediaServerLibraryRef, MediaServerMovie, MediaServerPage,
|
||||
MediaServerPageRequest, MediaServerSeason, MediaServerSeries, MediaServerStatus,
|
||||
playlist_mapper::media_server_image_ref_to_internal_url, MediaServerEpisode, MediaServerLibrary,
|
||||
MediaServerLibraryRef, MediaServerMovie, MediaServerPage, MediaServerPageRequest, MediaServerSeason,
|
||||
MediaServerSeries, MediaServerStatus,
|
||||
};
|
||||
use futures::{stream, StreamExt};
|
||||
use reqwest::header::{HeaderValue, AUTHORIZATION};
|
||||
@@ -329,6 +383,75 @@ mod tests {
|
||||
assert_eq!(provider, PlaybackOrigin::Provider);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_media_server_image_ref_roundtrips_internal_urls() {
|
||||
let emby = MediaServerImageRef::Emby {
|
||||
input_name: "emby/input".into(),
|
||||
server_id: "server/one+".into(),
|
||||
item_id: "item?one".into(),
|
||||
image_kind: "Primary".into(),
|
||||
tag: Some("tag/one+?".into()),
|
||||
};
|
||||
assert_eq!(
|
||||
parse_media_server_image_ref(&media_server_image_ref_to_internal_url(&emby)).expect("emby image ref parses"),
|
||||
emby
|
||||
);
|
||||
|
||||
let jellyfin = MediaServerImageRef::Jellyfin {
|
||||
input_name: "jellyfin/input".into(),
|
||||
server_id: "server/two+".into(),
|
||||
item_id: "item?two".into(),
|
||||
image_kind: "Backdrop".into(),
|
||||
tag: Some("tag/two+?".into()),
|
||||
};
|
||||
assert_eq!(
|
||||
parse_media_server_image_ref(&media_server_image_ref_to_internal_url(&jellyfin))
|
||||
.expect("jellyfin image ref parses"),
|
||||
jellyfin
|
||||
);
|
||||
|
||||
let plex = MediaServerImageRef::Plex {
|
||||
input_name: "plex/input".into(),
|
||||
server_id: "server/three+".into(),
|
||||
rating_key: "rating?three".into(),
|
||||
image_path: "/library/metadata/1/thumb/2?X-Plex-Token=ignored+".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
parse_media_server_image_ref(&media_server_image_ref_to_internal_url(&plex)).expect("plex image ref parses"),
|
||||
plex
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_media_server_image_ref_reports_missing_query_parts_as_not_found() {
|
||||
let emby = parse_media_server_image_ref("media-server://image/emby/input/server/item")
|
||||
.expect_err("emby image_kind is required");
|
||||
assert_eq!(emby.kind, MediaServerErrorKind::MediaServerItemNotFound);
|
||||
|
||||
let jellyfin = parse_media_server_image_ref("media-server://image/jellyfin/input/server/item")
|
||||
.expect_err("jellyfin image_kind is required");
|
||||
assert_eq!(jellyfin.kind, MediaServerErrorKind::MediaServerItemNotFound);
|
||||
|
||||
let plex = parse_media_server_image_ref("media-server://image/plex/input/server/rating")
|
||||
.expect_err("plex image_path is required");
|
||||
assert_eq!(plex.kind, MediaServerErrorKind::MediaServerItemNotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_media_server_image_ref_rejects_invalid_url_shapes() {
|
||||
let wrong_prefix = parse_media_server_image_ref("media-server://plex/input/server/rating")
|
||||
.expect_err("image URLs require the image prefix");
|
||||
assert_eq!(wrong_prefix.kind, MediaServerErrorKind::MediaServerStreamOpenFailed);
|
||||
|
||||
let too_few_parts = parse_media_server_image_ref("media-server://image/plex/input/server")
|
||||
.expect_err("image URLs require enough path parts");
|
||||
assert_eq!(too_few_parts.kind, MediaServerErrorKind::MediaServerStreamOpenFailed);
|
||||
|
||||
let unsupported = parse_media_server_image_ref("media-server://image/kodi/input/server/item?image_kind=Primary")
|
||||
.expect_err("unsupported image schemes are rejected");
|
||||
assert_eq!(unsupported.kind, MediaServerErrorKind::MediaServerStreamOpenFailed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn media_server_auth_denied_stays_media_server_error() {
|
||||
let client = MockPlaybackClient {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::media_server::{
|
||||
MediaServerAudioTechnicalFacts, MediaServerCatalogSnapshot, MediaServerDescriptiveFacts, MediaServerEpisode,
|
||||
MediaServerMovie, MediaServerProviderIdHint, MediaServerSeason, MediaServerSeries, MediaServerStreamRef,
|
||||
MediaServerImageRef, MediaServerMovie, MediaServerProviderIdHint, MediaServerSeason, MediaServerSeries, MediaServerStreamRef,
|
||||
MediaServerTechnicalFacts, MediaServerVideoTechnicalFacts,
|
||||
};
|
||||
use serde_json::{Map, Number, Value};
|
||||
@@ -20,6 +20,7 @@ pub fn media_server_catalog_snapshot_to_playlist(snapshot: &MediaServerCatalogSn
|
||||
if !snapshot.movies.is_empty() {
|
||||
groups.push(PlaylistGroup {
|
||||
id: 1,
|
||||
// TODO Presa read from config like LibraryPlaylistConfig
|
||||
title: "Media Server Movies".intern(),
|
||||
channels: snapshot.movies.iter().map(media_server_movie_to_playlist_item).collect(),
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
@@ -45,6 +46,7 @@ pub fn media_server_catalog_snapshot_to_playlist(snapshot: &MediaServerCatalogSn
|
||||
if !series_channels.is_empty() {
|
||||
groups.push(PlaylistGroup {
|
||||
id: next_group_id(groups.len()),
|
||||
// TODO Presa read from config like LibraryPlaylistConfig
|
||||
title: "Media Server Series".intern(),
|
||||
channels: series_channels,
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
@@ -102,21 +104,17 @@ fn series_parent_code_map(series: &[MediaServerSeries]) -> HashMap<MediaServerSe
|
||||
}
|
||||
|
||||
fn episode_parent_code(episode: &MediaServerEpisode, parent_codes: &HashMap<MediaServerSeriesKey<'_>, Arc<str>>) -> Arc<str> {
|
||||
episode
|
||||
.series_id
|
||||
.as_ref()
|
||||
.and_then(|series_id| {
|
||||
parent_codes
|
||||
.get(&media_server_series_parent_key(&episode.input_name, &episode.server_id, &episode.library_id, series_id))
|
||||
.map(Arc::clone)
|
||||
})
|
||||
.or_else(|| {
|
||||
episode
|
||||
.series_id
|
||||
.as_ref()
|
||||
.map(|series_id| stable_media_server_item_id(&episode.server_id, &episode.library_id, series_id, "series").intern())
|
||||
})
|
||||
.unwrap_or_else(|| "".intern())
|
||||
// Orphan episodes keep a stable media-server parent key when no SeriesInfo anchor exists.
|
||||
// materialize_media_server_series_info_episodes and rewrite_series_episode_parent_virtual_ids
|
||||
// only link episodes whose parent_code is a SeriesInfo uuid.intern(); this fallback is
|
||||
// intentionally stable but unlinkable rather than inventing a public series anchor.
|
||||
let Some(series_id) = episode.series_id.as_ref() else {
|
||||
return "".intern();
|
||||
};
|
||||
let stable_id = stable_media_server_item_id(&episode.server_id, &episode.library_id, series_id, "series");
|
||||
parent_codes
|
||||
.get(&media_server_series_parent_key(&episode.input_name, &episode.server_id, &episode.library_id, series_id))
|
||||
.map_or_else(|| stable_id.intern(), Arc::clone)
|
||||
}
|
||||
|
||||
fn media_server_series_parent_key<'a>(
|
||||
@@ -143,7 +141,8 @@ fn media_server_movie_to_playlist_item(movie: &MediaServerMovie) -> PlaylistItem
|
||||
);
|
||||
let uuid = generate_provider_playlist_uuid(&movie.input_name, &stable_id, PlaylistItemType::Video);
|
||||
let release_date = movie.release_date.clone().or_else(|| release_date_from_year(movie.year));
|
||||
let details = movie_details(movie, release_date);
|
||||
let image_url = movie.image_ref.as_ref().map(media_server_image_ref_to_internal_url).map(Arc::<str>::from);
|
||||
let details = movie_details(movie, release_date, image_url.clone());
|
||||
let rating = media_server_rating(movie.descriptive_facts.as_ref());
|
||||
|
||||
PlaylistItem {
|
||||
@@ -160,7 +159,7 @@ fn media_server_movie_to_playlist_item(movie: &MediaServerMovie) -> PlaylistItem
|
||||
additional_properties: Some(StreamProperties::Video(Box::new(VideoStreamProperties {
|
||||
name: movie.title.clone(),
|
||||
stream_id: 0,
|
||||
stream_icon: "".intern(),
|
||||
stream_icon: image_url.clone().unwrap_or_else(|| "".intern()),
|
||||
direct_source: "".intern(),
|
||||
category_id: 0,
|
||||
custom_sid: None,
|
||||
@@ -190,6 +189,7 @@ fn media_server_series_to_playlist_item(series: &MediaServerSeries, seasons: &[&
|
||||
let uuid = generate_provider_playlist_uuid(&series.input_name, &stable_id, PlaylistItemType::SeriesInfo);
|
||||
let release_date = series.release_date.clone().or_else(|| release_date_from_year(series.year));
|
||||
let rating = media_server_rating(series.descriptive_facts.as_ref()).unwrap_or_default();
|
||||
let image_url = series.image_ref.as_ref().map(media_server_image_ref_to_internal_url).map(Arc::<str>::from);
|
||||
|
||||
PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
@@ -205,7 +205,7 @@ fn media_server_series_to_playlist_item(series: &MediaServerSeries, seasons: &[&
|
||||
additional_properties: Some(StreamProperties::Series(Box::new(SeriesStreamProperties {
|
||||
name: series.title.clone(),
|
||||
series_id: 0,
|
||||
cover: "".intern(),
|
||||
cover: image_url.unwrap_or_else(|| "".intern()),
|
||||
backdrop_path: None,
|
||||
category_id: 0,
|
||||
cast: joined_values(series.descriptive_facts.as_ref().map(|facts| facts.cast.as_slice()))
|
||||
@@ -248,6 +248,8 @@ fn media_server_episode_to_playlist_item(episode: &MediaServerEpisode, parent_co
|
||||
episode.title.clone()
|
||||
};
|
||||
let technical = episode.technical_facts.as_ref();
|
||||
let plot = episode.descriptive_facts.as_ref().and_then(|facts| facts.summary.clone());
|
||||
let image_url = episode.image_ref.as_ref().map(media_server_image_ref_to_internal_url).map(Arc::<str>::from);
|
||||
|
||||
PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
@@ -268,8 +270,9 @@ fn media_server_episode_to_playlist_item(episode: &MediaServerEpisode, parent_co
|
||||
added: episode.source_version_hint.clone(),
|
||||
release_date: episode.release_date.clone(),
|
||||
series_release_date: None,
|
||||
plot,
|
||||
tmdb: provider_tmdb_id(&episode.provider_hints),
|
||||
movie_image: "".intern(),
|
||||
movie_image: image_url.unwrap_or_else(|| "".intern()),
|
||||
container_extension: media_server_container_extension(technical),
|
||||
video: technical.and_then(media_server_video_json),
|
||||
audio: technical.and_then(media_server_audio_json),
|
||||
@@ -279,7 +282,11 @@ fn media_server_episode_to_playlist_item(episode: &MediaServerEpisode, parent_co
|
||||
}
|
||||
}
|
||||
|
||||
fn movie_details(movie: &MediaServerMovie, release_date: Option<Arc<str>>) -> Option<VideoStreamDetailProperties> {
|
||||
fn movie_details(
|
||||
movie: &MediaServerMovie,
|
||||
release_date: Option<Arc<str>>,
|
||||
image_url: Option<Arc<str>>,
|
||||
) -> Option<VideoStreamDetailProperties> {
|
||||
let technical = movie.technical_facts.as_ref();
|
||||
let descriptive = movie.descriptive_facts.as_ref();
|
||||
let video = technical.and_then(media_server_video_json);
|
||||
@@ -292,8 +299,8 @@ fn movie_details(movie: &MediaServerMovie, release_date: Option<Arc<str>>) -> Op
|
||||
let details = VideoStreamDetailProperties {
|
||||
kinopoisk_url: None,
|
||||
o_name: descriptive.and_then(|facts| facts.original_title.clone()),
|
||||
cover_big: None,
|
||||
movie_image: None,
|
||||
cover_big: image_url.clone(),
|
||||
movie_image: image_url,
|
||||
release_date,
|
||||
episode_run_time: technical.and_then(|facts| facts.duration_secs.map(|duration| duration / 60)),
|
||||
youtube_trailer: None,
|
||||
@@ -324,6 +331,10 @@ fn series_details(
|
||||
series: &MediaServerSeries,
|
||||
seasons: &[&MediaServerSeason],
|
||||
) -> Option<SeriesStreamDetailProperties> {
|
||||
if series.year.is_none() && seasons.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let season_details = seasons
|
||||
.iter()
|
||||
.map(|season| SeriesStreamDetailSeasonProperties {
|
||||
@@ -339,10 +350,6 @@ fn series_details(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if series.year.is_none() && season_details.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(SeriesStreamDetailProperties {
|
||||
year: series.year,
|
||||
seasons: (!season_details.is_empty()).then_some(season_details),
|
||||
@@ -416,7 +423,9 @@ fn provider_tmdb_id(hints: &[MediaServerProviderIdHint]) -> Option<u32> {
|
||||
}
|
||||
|
||||
fn release_date_from_year(year: Option<u32>) -> Option<Arc<str>> {
|
||||
year.filter(|year| *year > 0).map(|year| Arc::<str>::from(format!("{year}-01-01")))
|
||||
// Synthetic year-only fallback for Xtream compatibility; callers must treat this as
|
||||
// non-authoritative and not as proof of an exact mid-year release date.
|
||||
year.filter(|year| *year > 0).map(|year| Arc::<str>::from(format!("{year}-07-01")))
|
||||
}
|
||||
|
||||
fn media_server_container_extension(technical: Option<&MediaServerTechnicalFacts>) -> Arc<str> {
|
||||
@@ -503,6 +512,55 @@ pub fn media_server_stream_ref_to_internal_url(stream_ref: &MediaServerStreamRef
|
||||
}
|
||||
}
|
||||
|
||||
pub fn media_server_image_ref_to_internal_url(image_ref: &MediaServerImageRef) -> String {
|
||||
match image_ref {
|
||||
MediaServerImageRef::Emby {
|
||||
input_name,
|
||||
server_id,
|
||||
item_id,
|
||||
image_kind,
|
||||
tag,
|
||||
} => format!(
|
||||
"media-server://image/emby/{}/{}/{}?image_kind={}{}",
|
||||
escape_internal_url_component(input_name),
|
||||
escape_internal_url_component(server_id),
|
||||
escape_internal_url_component(item_id),
|
||||
escape_internal_url_component(image_kind),
|
||||
tag.as_ref()
|
||||
.map(|tag| format!("&tag={}", escape_internal_url_component(tag)))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
MediaServerImageRef::Jellyfin {
|
||||
input_name,
|
||||
server_id,
|
||||
item_id,
|
||||
image_kind,
|
||||
tag,
|
||||
} => format!(
|
||||
"media-server://image/jellyfin/{}/{}/{}?image_kind={}{}",
|
||||
escape_internal_url_component(input_name),
|
||||
escape_internal_url_component(server_id),
|
||||
escape_internal_url_component(item_id),
|
||||
escape_internal_url_component(image_kind),
|
||||
tag.as_ref()
|
||||
.map(|tag| format!("&tag={}", escape_internal_url_component(tag)))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
MediaServerImageRef::Plex {
|
||||
input_name,
|
||||
server_id,
|
||||
rating_key,
|
||||
image_path,
|
||||
} => format!(
|
||||
"media-server://image/plex/{}/{}/{}?image_path={}",
|
||||
escape_internal_url_component(input_name),
|
||||
escape_internal_url_component(server_id),
|
||||
escape_internal_url_component(rating_key),
|
||||
escape_internal_url_component(image_path)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_internal_url_component(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
@@ -638,7 +696,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_media_server_series_and_seasons_as_catalog_anchors_without_image_projection() {
|
||||
fn maps_media_server_series_and_seasons_as_catalog_anchors() {
|
||||
let groups = media_server_catalog_snapshot_to_playlist(&MediaServerCatalogSnapshot {
|
||||
series: vec![series()],
|
||||
seasons: vec![season()],
|
||||
@@ -760,6 +818,10 @@ mod tests {
|
||||
let mut episode = episode();
|
||||
episode.provider_hints = vec![MediaServerProviderIdHint { namespace: "TmDb".into(), value: "67890".into() }];
|
||||
episode.release_date = Some("2024-02-03".into());
|
||||
episode.descriptive_facts = Some(MediaServerDescriptiveFacts {
|
||||
summary: Some("episode summary".into()),
|
||||
..MediaServerDescriptiveFacts::default()
|
||||
});
|
||||
episode.technical_facts = Some(MediaServerTechnicalFacts {
|
||||
container: Some("mp4".into()),
|
||||
video: Some(MediaServerVideoTechnicalFacts { codec: Some("h264".into()), width: Some(1_280), height: Some(720) }),
|
||||
@@ -779,7 +841,7 @@ mod tests {
|
||||
assert_eq!(video.tmdb, Some(12345));
|
||||
assert_eq!(video.container_extension.as_ref(), "mkv");
|
||||
let details = video.details.as_ref().expect("movie technical facts should create details");
|
||||
assert_eq!(details.release_date.as_deref(), Some("2024-01-01"));
|
||||
assert_eq!(details.release_date.as_deref(), Some("2024-07-01"));
|
||||
assert_eq!(details.duration_secs.as_deref(), Some("7200"));
|
||||
assert_eq!(details.bitrate, 8_000_000);
|
||||
assert_eq!(json_field(details.video.as_deref(), "codec_name"), Some(Value::String("hevc".to_string())));
|
||||
@@ -791,13 +853,14 @@ mod tests {
|
||||
};
|
||||
assert_eq!(episode.tmdb, Some(67890));
|
||||
assert_eq!(episode.release_date.as_deref(), Some("2024-02-03"));
|
||||
assert_eq!(episode.plot.as_deref(), Some("episode summary"));
|
||||
assert_eq!(episode.container_extension.as_ref(), "mp4");
|
||||
assert_eq!(json_field(episode.video.as_deref(), "height"), Some(Value::Number(720.into())));
|
||||
assert_eq!(json_field(episode.audio.as_deref(), "codec_name"), Some(Value::String("aac".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_project_media_server_image_refs_without_image_resource_contract() {
|
||||
fn projects_media_server_image_refs_as_internal_resource_urls() {
|
||||
let mut movie = movie();
|
||||
movie.image_ref = Some(MediaServerImageRef::Plex {
|
||||
input_name: "media_server".into(),
|
||||
@@ -832,22 +895,37 @@ mod tests {
|
||||
let Some(StreamProperties::Video(video)) = &groups[0].channels[0].header.additional_properties else {
|
||||
panic!("expected video properties");
|
||||
};
|
||||
assert_eq!(video.stream_icon.as_ref(), "");
|
||||
assert_eq!(
|
||||
video.stream_icon.as_ref(),
|
||||
"media-server://image/plex/media_server/server%2Fone/rating?image_path=%2Flibrary%2Fmetadata%2Frating%2Fthumb%2Fredacted"
|
||||
);
|
||||
let details = video.details.as_ref().expect("movie year should create details");
|
||||
assert!(details.cover_big.is_none());
|
||||
assert!(details.movie_image.is_none());
|
||||
assert_eq!(
|
||||
details.cover_big.as_deref(),
|
||||
Some("media-server://image/plex/media_server/server%2Fone/rating?image_path=%2Flibrary%2Fmetadata%2Frating%2Fthumb%2Fredacted")
|
||||
);
|
||||
assert_eq!(
|
||||
details.movie_image.as_deref(),
|
||||
Some("media-server://image/plex/media_server/server%2Fone/rating?image_path=%2Flibrary%2Fmetadata%2Frating%2Fthumb%2Fredacted")
|
||||
);
|
||||
assert!(details.backdrop_path.is_none());
|
||||
|
||||
let Some(StreamProperties::Series(series)) = &groups[1].channels[0].header.additional_properties else {
|
||||
panic!("expected series properties");
|
||||
};
|
||||
assert_eq!(series.cover.as_ref(), "");
|
||||
assert_eq!(
|
||||
series.cover.as_ref(),
|
||||
"media-server://image/jellyfin/media_server/server/series?image_kind=Primary&tag=tag-redacted"
|
||||
);
|
||||
assert!(series.backdrop_path.is_none());
|
||||
|
||||
let Some(StreamProperties::Episode(episode)) = &groups[1].channels[1].header.additional_properties else {
|
||||
panic!("expected episode properties");
|
||||
};
|
||||
assert_eq!(episode.movie_image.as_ref(), "");
|
||||
assert_eq!(
|
||||
episode.movie_image.as_ref(),
|
||||
"media-server://image/emby/media_server/server/episode?image_kind=Primary&tag=tag-redacted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -79,10 +79,64 @@ pub struct PlexDirectoryDto {
|
||||
pub item_type: Option<String>,
|
||||
#[serde(rename = "@title")]
|
||||
pub title: Option<String>,
|
||||
#[serde(rename = "@titleSort")]
|
||||
pub title_sort: Option<String>,
|
||||
#[serde(rename = "@originalTitle")]
|
||||
pub original_title: Option<String>,
|
||||
#[serde(rename = "@year")]
|
||||
pub year: Option<u32>,
|
||||
#[serde(rename = "@originallyAvailableAt")]
|
||||
pub originally_available_at: Option<String>,
|
||||
#[serde(rename = "@summary")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(rename = "@tagline")]
|
||||
pub tagline: Option<String>,
|
||||
#[serde(rename = "@studio")]
|
||||
pub studio: Option<String>,
|
||||
#[serde(rename = "@contentRating")]
|
||||
pub content_rating: Option<String>,
|
||||
#[serde(rename = "@contentRatingAge")]
|
||||
pub content_rating_age: Option<u32>,
|
||||
#[serde(rename = "@audienceRating")]
|
||||
pub audience_rating: Option<String>,
|
||||
#[serde(rename = "@guid")]
|
||||
pub guid: Option<String>,
|
||||
#[serde(rename = "@thumb")]
|
||||
pub thumb: Option<String>,
|
||||
#[serde(rename = "@art")]
|
||||
pub art: Option<String>,
|
||||
#[serde(rename = "@theme")]
|
||||
pub theme: Option<String>,
|
||||
#[serde(rename = "@parentRatingKey")]
|
||||
pub parent_rating_key: Option<String>,
|
||||
#[serde(rename = "@parentTitle")]
|
||||
pub parent_title: Option<String>,
|
||||
#[serde(rename = "@parentGuid")]
|
||||
pub parent_guid: Option<String>,
|
||||
#[serde(rename = "@parentIndex")]
|
||||
pub parent_index: Option<u32>,
|
||||
#[serde(rename = "@index")]
|
||||
pub index: Option<u32>,
|
||||
#[serde(rename = "@childCount")]
|
||||
pub child_count: Option<u32>,
|
||||
#[serde(rename = "@leafCount")]
|
||||
pub leaf_count: Option<u32>,
|
||||
#[serde(rename = "@viewedLeafCount")]
|
||||
pub viewed_leaf_count: Option<u32>,
|
||||
#[serde(rename = "@addedAt")]
|
||||
pub added_at: Option<i64>,
|
||||
#[serde(rename = "@updatedAt")]
|
||||
pub updated_at: Option<i64>,
|
||||
#[serde(rename = "Guid", default)]
|
||||
pub guids: Vec<PlexGuidDto>,
|
||||
#[serde(rename = "Genre", default)]
|
||||
pub genres: Vec<PlexTagDto>,
|
||||
#[serde(rename = "Country", default)]
|
||||
pub countries: Vec<PlexTagDto>,
|
||||
#[serde(rename = "Role", default)]
|
||||
pub roles: Vec<PlexTagDto>,
|
||||
#[serde(rename = "Image", default)]
|
||||
pub images: Vec<PlexImageDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
@@ -95,8 +149,26 @@ pub struct PlexVideoDto {
|
||||
pub item_type: Option<String>,
|
||||
#[serde(rename = "@title")]
|
||||
pub title: Option<String>,
|
||||
#[serde(rename = "@titleSort")]
|
||||
pub title_sort: Option<String>,
|
||||
#[serde(rename = "@originalTitle")]
|
||||
pub original_title: Option<String>,
|
||||
#[serde(rename = "@year")]
|
||||
pub year: Option<u32>,
|
||||
#[serde(rename = "@originallyAvailableAt")]
|
||||
pub originally_available_at: Option<String>,
|
||||
#[serde(rename = "@summary")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(rename = "@tagline")]
|
||||
pub tagline: Option<String>,
|
||||
#[serde(rename = "@studio")]
|
||||
pub studio: Option<String>,
|
||||
#[serde(rename = "@contentRating")]
|
||||
pub content_rating: Option<String>,
|
||||
#[serde(rename = "@contentRatingAge")]
|
||||
pub content_rating_age: Option<u32>,
|
||||
#[serde(rename = "@audienceRating")]
|
||||
pub audience_rating: Option<String>,
|
||||
#[serde(rename = "@guid")]
|
||||
pub guid: Option<String>,
|
||||
#[serde(rename = "@thumb")]
|
||||
@@ -105,8 +177,14 @@ pub struct PlexVideoDto {
|
||||
pub art: Option<String>,
|
||||
#[serde(rename = "@parentRatingKey")]
|
||||
pub parent_rating_key: Option<String>,
|
||||
#[serde(rename = "@parentGuid")]
|
||||
pub parent_guid: Option<String>,
|
||||
#[serde(rename = "@parentTitle")]
|
||||
pub parent_title: Option<String>,
|
||||
#[serde(rename = "@grandparentRatingKey")]
|
||||
pub grandparent_rating_key: Option<String>,
|
||||
#[serde(rename = "@grandparentGuid")]
|
||||
pub grandparent_guid: Option<String>,
|
||||
#[serde(rename = "@grandparentTitle")]
|
||||
pub grandparent_title: Option<String>,
|
||||
#[serde(rename = "@parentIndex")]
|
||||
@@ -119,6 +197,18 @@ pub struct PlexVideoDto {
|
||||
pub updated_at: Option<i64>,
|
||||
#[serde(rename = "Guid", default)]
|
||||
pub guids: Vec<PlexGuidDto>,
|
||||
#[serde(rename = "Genre", default)]
|
||||
pub genres: Vec<PlexTagDto>,
|
||||
#[serde(rename = "Country", default)]
|
||||
pub countries: Vec<PlexTagDto>,
|
||||
#[serde(rename = "Director", default)]
|
||||
pub directors: Vec<PlexTagDto>,
|
||||
#[serde(rename = "Writer", default)]
|
||||
pub writers: Vec<PlexTagDto>,
|
||||
#[serde(rename = "Role", default)]
|
||||
pub roles: Vec<PlexTagDto>,
|
||||
#[serde(rename = "Image", default)]
|
||||
pub images: Vec<PlexImageDto>,
|
||||
#[serde(rename = "Media", default)]
|
||||
pub media: Vec<PlexMediaDto>,
|
||||
}
|
||||
@@ -129,6 +219,33 @@ pub struct PlexGuidDto {
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct PlexTagDto {
|
||||
#[serde(rename = "@tag")]
|
||||
pub tag: Option<String>,
|
||||
#[serde(rename = "@title")]
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
impl PlexTagDto {
|
||||
pub fn value(&self) -> Option<std::sync::Arc<str>> {
|
||||
self.tag
|
||||
.as_deref()
|
||||
.or(self.title.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(std::sync::Arc::<str>::from)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct PlexImageDto {
|
||||
#[serde(rename = "@type")]
|
||||
pub image_type: Option<String>,
|
||||
#[serde(rename = "@url")]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct PlexMediaDto {
|
||||
#[serde(rename = "@id")]
|
||||
@@ -143,6 +260,14 @@ pub struct PlexMediaDto {
|
||||
pub width: Option<u32>,
|
||||
#[serde(rename = "@height")]
|
||||
pub height: Option<u32>,
|
||||
#[serde(rename = "@audioChannels")]
|
||||
pub audio_channels: Option<u32>,
|
||||
#[serde(rename = "@audioCodec")]
|
||||
pub audio_codec: Option<String>,
|
||||
#[serde(rename = "@videoCodec")]
|
||||
pub video_codec: Option<String>,
|
||||
#[serde(rename = "@videoResolution")]
|
||||
pub video_resolution: Option<String>,
|
||||
#[serde(rename = "Part", default)]
|
||||
pub parts: Vec<PlexPartDto>,
|
||||
}
|
||||
@@ -193,6 +318,10 @@ mod tests {
|
||||
assert_eq!(movie.rating_key.as_deref(), Some("rating-redacted-1"));
|
||||
assert_eq!(part.key.as_deref(), Some("/library/parts/part-redacted/file.mkv"));
|
||||
assert!(part.file.as_deref().is_some_and(|file| file.contains("/redacted/")));
|
||||
assert_eq!(movie.guids.len(), 1);
|
||||
assert_eq!(movie.originally_available_at.as_deref(), Some("2024-01-02"));
|
||||
assert_eq!(movie.media[0].video_codec.as_deref(), Some("hevc"));
|
||||
assert_eq!(movie.media[0].audio_channels, Some(6));
|
||||
assert_eq!(movie.genres[0].tag.as_deref(), Some("Drama"));
|
||||
assert_eq!(movie.guids.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
use crate::media_server::plex::dto::{PlexDirectoryDto, PlexGuidDto, PlexMediaDto, PlexSectionDto, PlexVideoDto};
|
||||
use crate::media_server::{
|
||||
MediaServerAudioTechnicalFacts, MediaServerDescriptiveFacts, MediaServerEpisode, MediaServerImageRef,
|
||||
MediaServerLibrary, MediaServerLibraryRef, MediaServerMovie, MediaServerProviderIdHint,
|
||||
MediaServerSeason, MediaServerSeries, MediaServerStreamRef, MediaServerTechnicalFacts,
|
||||
MediaServerVideoTechnicalFacts,
|
||||
};
|
||||
use shared::model::{MediaServerLibraryKind, MediaServerLibrarySelector};
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn plex_section_to_library(
|
||||
input_name: &Arc<str>,
|
||||
server_id: &Arc<str>,
|
||||
section: &PlexSectionDto,
|
||||
) -> Option<MediaServerLibrary> {
|
||||
let library_id = non_blank(section.key.as_deref())?;
|
||||
let name = non_blank(section.title.as_deref()).unwrap_or_else(|| library_id.clone());
|
||||
Some(MediaServerLibrary {
|
||||
reference: MediaServerLibraryRef {
|
||||
input_name: Arc::clone(input_name),
|
||||
server_id: Arc::clone(server_id),
|
||||
library_id,
|
||||
},
|
||||
name,
|
||||
kind: plex_library_kind(section.section_type.as_deref()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn plex_section_matches_selector(section: &PlexSectionDto, selector: &MediaServerLibrarySelector) -> bool {
|
||||
match selector {
|
||||
MediaServerLibrarySelector::Name(name) => matches_trimmed(section.title.as_deref(), name),
|
||||
MediaServerLibrarySelector::Detailed(details) => {
|
||||
let identity_matches = details.key.as_deref().is_some_and(|key| matches_trimmed(section.key.as_deref(), key))
|
||||
|| details.id.as_deref().is_some_and(|id| matches_trimmed(section.key.as_deref(), id))
|
||||
|| details.name.as_deref().is_some_and(|name| matches_trimmed(section.title.as_deref(), name));
|
||||
identity_matches && details.kind.is_none_or(|kind| plex_section_kind_matches(section.section_type.as_deref(), kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plex_video_to_movie(
|
||||
input_name: &Arc<str>,
|
||||
server_id: &Arc<str>,
|
||||
library_id: &Arc<str>,
|
||||
video: &PlexVideoDto,
|
||||
) -> Option<MediaServerMovie> {
|
||||
let rating_key = non_blank(video.rating_key.as_deref())?;
|
||||
let title = non_blank(video.title.as_deref()).unwrap_or_else(|| Arc::<str>::from("Plex Movie"));
|
||||
let media = first_media(&video.media);
|
||||
|
||||
Some(MediaServerMovie {
|
||||
input_name: Arc::clone(input_name),
|
||||
server_id: Arc::clone(server_id),
|
||||
library_id: Arc::clone(library_id),
|
||||
item_id: Arc::clone(&rating_key),
|
||||
title,
|
||||
year: video.year,
|
||||
release_date: non_blank(video.originally_available_at.as_deref()),
|
||||
source_version_hint: source_version_hint(video.updated_at, video.added_at),
|
||||
provider_hints: provider_hints(video.guid.as_deref(), &video.guids),
|
||||
descriptive_facts: video_descriptive_facts(video),
|
||||
technical_facts: media.and_then(media_technical_facts),
|
||||
stream_ref: plex_stream_ref(input_name, server_id, &rating_key, media),
|
||||
image_ref: plex_image_ref(input_name, server_id, &rating_key, [&video.thumb, &video.art]),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn plex_directory_to_series(
|
||||
input_name: &Arc<str>,
|
||||
server_id: &Arc<str>,
|
||||
library_id: &Arc<str>,
|
||||
directory: &PlexDirectoryDto,
|
||||
) -> Option<MediaServerSeries> {
|
||||
let rating_key = non_blank(directory.rating_key.as_deref())?;
|
||||
let title = non_blank(directory.title.as_deref()).unwrap_or_else(|| Arc::<str>::from("Plex Series"));
|
||||
|
||||
Some(MediaServerSeries {
|
||||
input_name: Arc::clone(input_name),
|
||||
server_id: Arc::clone(server_id),
|
||||
library_id: Arc::clone(library_id),
|
||||
item_id: Arc::clone(&rating_key),
|
||||
title,
|
||||
year: directory.year,
|
||||
release_date: non_blank(directory.originally_available_at.as_deref()),
|
||||
source_version_hint: source_version_hint(directory.updated_at, directory.added_at),
|
||||
provider_hints: provider_hints(directory.guid.as_deref(), &directory.guids),
|
||||
descriptive_facts: directory_descriptive_facts(directory),
|
||||
child_count: directory.child_count,
|
||||
episode_count: directory.leaf_count,
|
||||
image_ref: plex_image_ref(input_name, server_id, &rating_key, [&directory.thumb, &directory.art, &directory.theme]),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn plex_directory_to_season(
|
||||
input_name: &Arc<str>,
|
||||
server_id: &Arc<str>,
|
||||
library_id: &Arc<str>,
|
||||
directory: &PlexDirectoryDto,
|
||||
) -> Option<MediaServerSeason> {
|
||||
let rating_key = non_blank(directory.rating_key.as_deref())?;
|
||||
let title = non_blank(directory.title.as_deref()).unwrap_or_else(|| Arc::<str>::from("Plex Season"));
|
||||
|
||||
Some(MediaServerSeason {
|
||||
input_name: Arc::clone(input_name),
|
||||
server_id: Arc::clone(server_id),
|
||||
library_id: Arc::clone(library_id),
|
||||
item_id: Arc::clone(&rating_key),
|
||||
series_id: non_blank(directory.parent_rating_key.as_deref()),
|
||||
series_title: non_blank(directory.parent_title.as_deref()),
|
||||
title,
|
||||
season: directory.index,
|
||||
year: directory.year,
|
||||
release_date: non_blank(directory.originally_available_at.as_deref()),
|
||||
source_version_hint: source_version_hint(directory.updated_at, directory.added_at),
|
||||
provider_hints: provider_hints(directory.guid.as_deref(), &directory.guids),
|
||||
descriptive_facts: directory_descriptive_facts(directory),
|
||||
episode_count: directory.leaf_count,
|
||||
image_ref: plex_image_ref(input_name, server_id, &rating_key, [&directory.thumb, &directory.art]),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn plex_video_to_episode(
|
||||
input_name: &Arc<str>,
|
||||
server_id: &Arc<str>,
|
||||
library_id: &Arc<str>,
|
||||
video: &PlexVideoDto,
|
||||
) -> Option<MediaServerEpisode> {
|
||||
let rating_key = non_blank(video.rating_key.as_deref())?;
|
||||
let title = non_blank(video.title.as_deref()).unwrap_or_else(|| Arc::<str>::from("Plex Episode"));
|
||||
let media = first_media(&video.media);
|
||||
|
||||
Some(MediaServerEpisode {
|
||||
input_name: Arc::clone(input_name),
|
||||
server_id: Arc::clone(server_id),
|
||||
library_id: Arc::clone(library_id),
|
||||
item_id: Arc::clone(&rating_key),
|
||||
series_id: non_blank(video.grandparent_rating_key.as_deref()),
|
||||
series_title: non_blank(video.grandparent_title.as_deref()),
|
||||
title,
|
||||
season: video.parent_index,
|
||||
episode: video.index,
|
||||
release_date: non_blank(video.originally_available_at.as_deref()),
|
||||
source_version_hint: source_version_hint(video.updated_at, video.added_at),
|
||||
provider_hints: provider_hints(video.guid.as_deref(), &video.guids),
|
||||
descriptive_facts: video_descriptive_facts(video),
|
||||
technical_facts: media.and_then(media_technical_facts),
|
||||
stream_ref: plex_stream_ref(input_name, server_id, &rating_key, media),
|
||||
image_ref: plex_image_ref(input_name, server_id, &rating_key, [&video.thumb, &video.art]),
|
||||
})
|
||||
}
|
||||
|
||||
fn plex_section_kind_matches(section_type: Option<&str>, expected: MediaServerLibraryKind) -> bool {
|
||||
let kind = plex_library_kind(section_type);
|
||||
match expected {
|
||||
MediaServerLibraryKind::Movies => matches!(kind, MediaServerLibraryKind::Movies),
|
||||
MediaServerLibraryKind::TvShows => matches!(kind, MediaServerLibraryKind::TvShows),
|
||||
MediaServerLibraryKind::Unsupported => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn plex_library_kind(section_type: Option<&str>) -> MediaServerLibraryKind {
|
||||
match section_type.map(str::trim) {
|
||||
Some(value) if value.eq_ignore_ascii_case("movie") => MediaServerLibraryKind::Movies,
|
||||
Some(value) if value.eq_ignore_ascii_case("show") => MediaServerLibraryKind::TvShows,
|
||||
_ => MediaServerLibraryKind::Unsupported,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_trimmed(candidate: Option<&str>, expected: &str) -> bool {
|
||||
candidate.is_some_and(|candidate| candidate.trim() == expected.trim())
|
||||
}
|
||||
|
||||
fn first_media(media: &[PlexMediaDto]) -> Option<&PlexMediaDto> {
|
||||
media
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, media)| media_has_catalog_facts(media))
|
||||
.max_by_key(|(index, media)| media_preference_key(media, *index))
|
||||
.map(|(_, media)| media)
|
||||
}
|
||||
|
||||
fn media_preference_key(media: &PlexMediaDto, index: usize) -> (bool, u32, u64, Reverse<usize>) {
|
||||
(
|
||||
first_part_key(media).is_some(),
|
||||
media.bitrate.unwrap_or_default(),
|
||||
media_resolution_pixels(media),
|
||||
Reverse(index),
|
||||
)
|
||||
}
|
||||
|
||||
fn media_resolution_pixels(media: &PlexMediaDto) -> u64 {
|
||||
u64::from(media.width.unwrap_or_default()) * u64::from(media.height.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn media_has_catalog_facts(media: &PlexMediaDto) -> bool {
|
||||
media.container.as_deref().is_some_and(|value| !value.trim().is_empty())
|
||||
|| media.duration.is_some()
|
||||
|| media.bitrate.is_some()
|
||||
|| media.width.is_some()
|
||||
|| media.height.is_some()
|
||||
|| media.video_codec.as_deref().is_some_and(|value| !value.trim().is_empty())
|
||||
|| media.audio_codec.as_deref().is_some_and(|value| !value.trim().is_empty())
|
||||
|| media.audio_channels.is_some()
|
||||
|| first_part_key(media).is_some()
|
||||
}
|
||||
|
||||
fn plex_stream_ref(
|
||||
input_name: &Arc<str>,
|
||||
server_id: &Arc<str>,
|
||||
rating_key: &Arc<str>,
|
||||
media: Option<&PlexMediaDto>,
|
||||
) -> Option<MediaServerStreamRef> {
|
||||
Some(MediaServerStreamRef::Plex {
|
||||
input_name: Arc::clone(input_name),
|
||||
server_id: Arc::clone(server_id),
|
||||
rating_key: Arc::clone(rating_key),
|
||||
part_key: first_part_key(media?)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn first_part_key(media: &PlexMediaDto) -> Option<Arc<str>> {
|
||||
media.parts.iter().find_map(|part| non_blank(part.key.as_deref()))
|
||||
}
|
||||
|
||||
fn plex_image_ref<const N: usize>(
|
||||
input_name: &Arc<str>,
|
||||
server_id: &Arc<str>,
|
||||
rating_key: &Arc<str>,
|
||||
candidates: [&Option<String>; N],
|
||||
) -> Option<MediaServerImageRef> {
|
||||
let image_path = candidates.into_iter().find_map(|candidate| non_blank(candidate.as_deref()))?;
|
||||
Some(MediaServerImageRef::Plex {
|
||||
input_name: Arc::clone(input_name),
|
||||
server_id: Arc::clone(server_id),
|
||||
rating_key: Arc::clone(rating_key),
|
||||
image_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn video_descriptive_facts(video: &PlexVideoDto) -> Option<MediaServerDescriptiveFacts> {
|
||||
let facts = MediaServerDescriptiveFacts {
|
||||
original_title: non_blank(video.original_title.as_deref()),
|
||||
sort_title: non_blank(video.title_sort.as_deref()),
|
||||
summary: non_blank(video.summary.as_deref()),
|
||||
tagline: non_blank(video.tagline.as_deref()),
|
||||
studio: non_blank(video.studio.as_deref()),
|
||||
content_rating: non_blank(video.content_rating.as_deref()),
|
||||
parental_age: video.content_rating_age,
|
||||
audience_rating: non_blank(video.audience_rating.as_deref()),
|
||||
genres: tag_values(&video.genres),
|
||||
countries: tag_values(&video.countries),
|
||||
directors: tag_values(&video.directors),
|
||||
writers: tag_values(&video.writers),
|
||||
cast: tag_values(&video.roles),
|
||||
..MediaServerDescriptiveFacts::default()
|
||||
};
|
||||
has_descriptive_facts(&facts).then_some(facts)
|
||||
}
|
||||
|
||||
fn directory_descriptive_facts(directory: &PlexDirectoryDto) -> Option<MediaServerDescriptiveFacts> {
|
||||
let facts = MediaServerDescriptiveFacts {
|
||||
original_title: non_blank(directory.original_title.as_deref()),
|
||||
sort_title: non_blank(directory.title_sort.as_deref()),
|
||||
summary: non_blank(directory.summary.as_deref()),
|
||||
tagline: non_blank(directory.tagline.as_deref()),
|
||||
studio: non_blank(directory.studio.as_deref()),
|
||||
content_rating: non_blank(directory.content_rating.as_deref()),
|
||||
parental_age: directory.content_rating_age,
|
||||
audience_rating: non_blank(directory.audience_rating.as_deref()),
|
||||
genres: tag_values(&directory.genres),
|
||||
countries: tag_values(&directory.countries),
|
||||
cast: tag_values(&directory.roles),
|
||||
..MediaServerDescriptiveFacts::default()
|
||||
};
|
||||
has_descriptive_facts(&facts).then_some(facts)
|
||||
}
|
||||
|
||||
fn has_descriptive_facts(facts: &MediaServerDescriptiveFacts) -> bool {
|
||||
facts.original_title.is_some()
|
||||
|| facts.sort_title.is_some()
|
||||
|| facts.summary.is_some()
|
||||
|| facts.tagline.is_some()
|
||||
|| facts.studio.is_some()
|
||||
|| facts.network.is_some()
|
||||
|| facts.content_rating.is_some()
|
||||
|| facts.parental_age.is_some()
|
||||
|| facts.audience_rating.is_some()
|
||||
|| !facts.genres.is_empty()
|
||||
|| !facts.countries.is_empty()
|
||||
|| !facts.directors.is_empty()
|
||||
|| !facts.writers.is_empty()
|
||||
|| !facts.cast.is_empty()
|
||||
|| !facts.crew.is_empty()
|
||||
}
|
||||
|
||||
fn media_technical_facts(media: &PlexMediaDto) -> Option<MediaServerTechnicalFacts> {
|
||||
let video = MediaServerVideoTechnicalFacts {
|
||||
codec: non_blank(media.video_codec.as_deref()),
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
};
|
||||
let audio = MediaServerAudioTechnicalFacts {
|
||||
codec: non_blank(media.audio_codec.as_deref()),
|
||||
channels: media.audio_channels,
|
||||
};
|
||||
let facts = MediaServerTechnicalFacts {
|
||||
container: non_blank(media.container.as_deref()).or_else(|| first_part_container(media)),
|
||||
duration_secs: plex_duration_ms_to_secs(media.duration),
|
||||
bitrate: media.bitrate.and_then(plex_bitrate_kbps_to_bps),
|
||||
video: has_video_facts(&video).then_some(video),
|
||||
audio: has_audio_facts(&audio).then_some(audio),
|
||||
};
|
||||
has_technical_facts(&facts).then_some(facts)
|
||||
}
|
||||
|
||||
fn first_part_container(media: &PlexMediaDto) -> Option<Arc<str>> {
|
||||
media.parts.iter().find_map(|part| non_blank(part.container.as_deref()))
|
||||
}
|
||||
|
||||
fn has_video_facts(facts: &MediaServerVideoTechnicalFacts) -> bool {
|
||||
facts.codec.is_some() || facts.width.is_some() || facts.height.is_some()
|
||||
}
|
||||
|
||||
fn has_audio_facts(facts: &MediaServerAudioTechnicalFacts) -> bool {
|
||||
facts.codec.is_some() || facts.channels.is_some()
|
||||
}
|
||||
|
||||
fn has_technical_facts(facts: &MediaServerTechnicalFacts) -> bool {
|
||||
facts.container.is_some()
|
||||
|| facts.duration_secs.is_some()
|
||||
|| facts.bitrate.is_some()
|
||||
|| facts.video.is_some()
|
||||
|| facts.audio.is_some()
|
||||
}
|
||||
|
||||
fn plex_duration_ms_to_secs(duration: Option<u64>) -> Option<u32> {
|
||||
let duration_ms = duration?;
|
||||
let rounded_secs = duration_ms.saturating_add(500) / 1000;
|
||||
u32::try_from(rounded_secs).ok().filter(|value| *value > 0)
|
||||
}
|
||||
|
||||
fn plex_bitrate_kbps_to_bps(bitrate_kbps: u32) -> Option<u32> {
|
||||
bitrate_kbps.checked_mul(1000).filter(|value| *value > 0)
|
||||
}
|
||||
|
||||
fn provider_hints(guid: Option<&str>, guids: &[PlexGuidDto]) -> Vec<MediaServerProviderIdHint> {
|
||||
let mut seen = HashSet::<(String, String)>::new();
|
||||
let mut hints = Vec::new();
|
||||
|
||||
for candidate in guid.into_iter().chain(guids.iter().filter_map(|guid| guid.id.as_deref())) {
|
||||
let Some((namespace, value)) = parse_guid(candidate) else { continue };
|
||||
if seen.insert((namespace.to_string(), value.to_string())) {
|
||||
hints.push(MediaServerProviderIdHint { namespace, value });
|
||||
}
|
||||
}
|
||||
|
||||
hints
|
||||
}
|
||||
|
||||
fn parse_guid(value: &str) -> Option<(Arc<str>, Arc<str>)> {
|
||||
let value = value.trim();
|
||||
let (namespace, id) = value.split_once("://")?;
|
||||
let namespace = normalize_guid_namespace(namespace)?;
|
||||
let id = id.split('?').next().unwrap_or(id).trim();
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((namespace, Arc::<str>::from(id)))
|
||||
}
|
||||
|
||||
fn normalize_guid_namespace(namespace: &str) -> Option<Arc<str>> {
|
||||
let namespace = namespace.trim().to_ascii_lowercase();
|
||||
let namespace = namespace.strip_prefix("com.plexapp.agents.").unwrap_or(&namespace);
|
||||
let namespace = match namespace {
|
||||
"themoviedb" => "tmdb",
|
||||
"thetvdb" => "tvdb",
|
||||
value => value,
|
||||
};
|
||||
(!namespace.is_empty()).then(|| Arc::<str>::from(namespace))
|
||||
}
|
||||
|
||||
fn tag_values(tags: &[crate::media_server::plex::dto::PlexTagDto]) -> Vec<Arc<str>> {
|
||||
tags.iter().filter_map(crate::media_server::plex::dto::PlexTagDto::value).collect()
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<&str>) -> Option<Arc<str>> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty()).map(Arc::<str>::from)
|
||||
}
|
||||
|
||||
fn source_version_hint(updated_at: Option<i64>, added_at: Option<i64>) -> Option<Arc<str>> {
|
||||
updated_at.or(added_at).filter(|value| *value > 0).map(|value| Arc::<str>::from(value.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::media_server::plex::dto::{PlexMediaContainerDto, PlexMediaDto, PlexPartDto};
|
||||
use crate::media_server::test_fixtures::{PLEX_EPISODES_XML, PLEX_MOVIES_XML, PLEX_SEASONS_XML, PLEX_SHOWS_XML};
|
||||
|
||||
fn input_name() -> Arc<str> { Arc::<str>::from("media_server") }
|
||||
fn server_id() -> Arc<str> { Arc::<str>::from("machine-redacted") }
|
||||
fn library_id() -> Arc<str> { Arc::<str>::from("library-redacted") }
|
||||
|
||||
#[test]
|
||||
fn maps_plex_movie_catalog_row_to_media_server_movie_without_leaking_part_file() {
|
||||
let container: PlexMediaContainerDto = quick_xml::de::from_str(PLEX_MOVIES_XML).expect("fixture parses");
|
||||
let movie = plex_video_to_movie(&input_name(), &server_id(), &library_id(), &container.videos[0])
|
||||
.expect("movie maps");
|
||||
|
||||
assert_eq!(movie.item_id.as_ref(), "rating-redacted-1");
|
||||
assert_eq!(movie.title.as_ref(), "Movie Redacted");
|
||||
assert_eq!(movie.year, Some(2024));
|
||||
assert_eq!(movie.release_date.as_deref(), Some("2024-01-02"));
|
||||
assert_eq!(movie.source_version_hint.as_deref(), Some("1700000001"));
|
||||
assert!(movie.provider_hints.iter().any(|hint| hint.namespace.as_ref() == "tmdb" && hint.value.as_ref() == "12345"));
|
||||
assert!(movie.provider_hints.iter().any(|hint| hint.namespace.as_ref() == "imdb" && hint.value.as_ref() == "tt-redacted"));
|
||||
|
||||
let descriptive = movie.descriptive_facts.as_ref().expect("descriptive facts");
|
||||
assert_eq!(descriptive.original_title.as_deref(), Some("Original Movie Redacted"));
|
||||
assert_eq!(descriptive.summary.as_deref(), Some("Movie summary redacted"));
|
||||
assert_eq!(descriptive.content_rating.as_deref(), Some("PG-13"));
|
||||
assert_eq!(descriptive.parental_age, Some(13));
|
||||
assert_eq!(descriptive.audience_rating.as_deref(), Some("8.2"));
|
||||
assert_eq!(descriptive.genres.iter().map(AsRef::as_ref).collect::<Vec<_>>(), vec!["Drama"]);
|
||||
assert_eq!(descriptive.directors.iter().map(AsRef::as_ref).collect::<Vec<_>>(), vec!["Director Redacted"]);
|
||||
assert_eq!(descriptive.cast.iter().map(AsRef::as_ref).collect::<Vec<_>>(), vec!["Actor Redacted"]);
|
||||
|
||||
let technical = movie.technical_facts.as_ref().expect("technical facts");
|
||||
assert_eq!(technical.container.as_deref(), Some("mkv"));
|
||||
assert_eq!(technical.duration_secs, Some(7200));
|
||||
assert_eq!(technical.bitrate, Some(8_000_000));
|
||||
assert_eq!(technical.video.as_ref().and_then(|video| video.codec.as_deref()), Some("hevc"));
|
||||
assert_eq!(technical.video.as_ref().and_then(|video| video.width), Some(1920));
|
||||
assert_eq!(technical.audio.as_ref().and_then(|audio| audio.codec.as_deref()), Some("eac3"));
|
||||
assert_eq!(technical.audio.as_ref().and_then(|audio| audio.channels), Some(6));
|
||||
|
||||
assert_eq!(
|
||||
movie.stream_ref,
|
||||
Some(MediaServerStreamRef::Plex {
|
||||
input_name: input_name(),
|
||||
server_id: server_id(),
|
||||
rating_key: "rating-redacted-1".into(),
|
||||
part_key: "/library/parts/part-redacted/file.mkv".into(),
|
||||
})
|
||||
);
|
||||
assert!(format!("{movie:?}").contains("/library/parts/part-redacted/file.mkv"));
|
||||
assert!(!format!("{movie:?}").contains("/redacted/upstream/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_plex_show_and_season_rows_as_catalog_anchors() {
|
||||
let shows: PlexMediaContainerDto = quick_xml::de::from_str(PLEX_SHOWS_XML).expect("show fixture parses");
|
||||
let seasons: PlexMediaContainerDto = quick_xml::de::from_str(PLEX_SEASONS_XML).expect("season fixture parses");
|
||||
|
||||
let series = plex_directory_to_series(&input_name(), &server_id(), &library_id(), &shows.directories[0])
|
||||
.expect("series maps");
|
||||
assert_eq!(series.item_id.as_ref(), "series-redacted-1");
|
||||
assert_eq!(series.title.as_ref(), "Show Redacted");
|
||||
assert_eq!(series.episode_count, Some(2));
|
||||
assert_eq!(series.child_count, Some(1));
|
||||
assert!(series.provider_hints.iter().any(|hint| hint.namespace.as_ref() == "tmdb" && hint.value.as_ref() == "222"));
|
||||
let series_facts = series.descriptive_facts.as_ref().expect("series descriptive facts");
|
||||
assert_eq!(series_facts.summary.as_deref(), Some("Show summary redacted"));
|
||||
assert_eq!(series_facts.genres.iter().map(AsRef::as_ref).collect::<Vec<_>>(), vec!["Mystery"]);
|
||||
assert!(matches!(series.image_ref, Some(MediaServerImageRef::Plex { .. })));
|
||||
|
||||
let season = plex_directory_to_season(&input_name(), &server_id(), &library_id(), &seasons.directories[0])
|
||||
.expect("season maps");
|
||||
assert_eq!(season.item_id.as_ref(), "season-redacted-1");
|
||||
assert_eq!(season.series_id.as_deref(), Some("series-redacted-1"));
|
||||
assert_eq!(season.series_title.as_deref(), Some("Show Redacted"));
|
||||
assert_eq!(season.season, Some(1));
|
||||
assert_eq!(season.episode_count, Some(2));
|
||||
assert!(season.provider_hints.iter().any(|hint| hint.namespace.as_ref() == "tvdb" && hint.value.as_ref() == "333"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_plex_episode_catalog_row_to_playable_episode() {
|
||||
let container: PlexMediaContainerDto = quick_xml::de::from_str(PLEX_EPISODES_XML).expect("fixture parses");
|
||||
let episode = plex_video_to_episode(&input_name(), &server_id(), &library_id(), &container.videos[0])
|
||||
.expect("episode maps");
|
||||
|
||||
assert_eq!(episode.item_id.as_ref(), "episode-redacted-1");
|
||||
assert_eq!(episode.series_id.as_deref(), Some("series-redacted-1"));
|
||||
assert_eq!(episode.series_title.as_deref(), Some("Show Redacted"));
|
||||
assert_eq!(episode.season, Some(1));
|
||||
assert_eq!(episode.episode, Some(2));
|
||||
assert_eq!(episode.release_date.as_deref(), Some("2024-02-03"));
|
||||
assert!(episode.provider_hints.iter().any(|hint| hint.namespace.as_ref() == "tmdb" && hint.value.as_ref() == "67890"));
|
||||
assert!(!episode.provider_hints.iter().any(|hint| hint.value.as_ref() == "222"));
|
||||
assert_eq!(
|
||||
episode.stream_ref,
|
||||
Some(MediaServerStreamRef::Plex {
|
||||
input_name: input_name(),
|
||||
server_id: server_id(),
|
||||
rating_key: "episode-redacted-1".into(),
|
||||
part_key: "/library/parts/episode-part-redacted/file.mkv".into(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_rows_without_rating_key_and_keeps_identity_without_part_key() {
|
||||
let mut container: PlexMediaContainerDto = quick_xml::de::from_str(PLEX_MOVIES_XML).expect("fixture parses");
|
||||
container.videos[0].rating_key = None;
|
||||
assert!(plex_video_to_movie(&input_name(), &server_id(), &library_id(), &container.videos[0]).is_none());
|
||||
|
||||
let mut container: PlexMediaContainerDto = quick_xml::de::from_str(PLEX_MOVIES_XML).expect("fixture parses");
|
||||
container.videos[0].media[0].parts[0].key = None;
|
||||
let movie = plex_video_to_movie(&input_name(), &server_id(), &library_id(), &container.videos[0])
|
||||
.expect("movie identity remains valid");
|
||||
assert!(movie.stream_ref.is_none());
|
||||
assert!(movie.technical_facts.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_media_prefers_best_deterministic_playable_version() {
|
||||
let low_bitrate = media_with_part("low", Some(1_000), Some(1920), Some(1080));
|
||||
let high_bitrate = media_with_part("high", Some(4_000), Some(1280), Some(720));
|
||||
let high_resolution = media_with_part("resolution", Some(4_000), Some(3840), Some(2160));
|
||||
let metadata_only = PlexMediaDto {
|
||||
id: Some("metadata".to_string()),
|
||||
container: Some("mkv".to_string()),
|
||||
duration: None,
|
||||
bitrate: Some(10_000),
|
||||
width: Some(7680),
|
||||
height: Some(4320),
|
||||
audio_channels: None,
|
||||
audio_codec: None,
|
||||
video_codec: None,
|
||||
video_resolution: None,
|
||||
parts: Vec::new(),
|
||||
};
|
||||
let media = vec![metadata_only, low_bitrate, high_resolution, high_bitrate];
|
||||
|
||||
let preferred = first_media(&media).expect("preferred media");
|
||||
|
||||
assert_eq!(preferred.parts[0].key.as_deref(), Some("/library/parts/resolution/file.mkv"));
|
||||
}
|
||||
|
||||
fn media_with_part(id: &str, bitrate: Option<u32>, width: Option<u32>, height: Option<u32>) -> PlexMediaDto {
|
||||
PlexMediaDto {
|
||||
id: Some(id.to_string()),
|
||||
container: Some("mkv".to_string()),
|
||||
duration: None,
|
||||
bitrate,
|
||||
width,
|
||||
height,
|
||||
audio_channels: None,
|
||||
audio_codec: None,
|
||||
video_codec: None,
|
||||
video_resolution: None,
|
||||
parts: vec![PlexPartDto {
|
||||
id: Some(id.to_string()),
|
||||
key: Some(format!("/library/parts/{id}/file.mkv")),
|
||||
size: None,
|
||||
file: Some("/redacted/upstream/path/file.mkv".to_string()),
|
||||
container: Some("mkv".to_string()),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_guid_hints_case_normalized_and_ignores_malformed_values() {
|
||||
let hints = provider_hints(
|
||||
Some("TmDb://123?lang=en"),
|
||||
&[
|
||||
PlexGuidDto { id: Some("com.plexapp.agents.imdb://tt-redacted?lang=en".to_string()) },
|
||||
PlexGuidDto { id: Some("malformed".to_string()) },
|
||||
PlexGuidDto { id: Some("tmdb://123".to_string()) },
|
||||
PlexGuidDto { id: Some("tvdb:// ".to_string()) },
|
||||
PlexGuidDto { id: Some("com.plexapp.agents.themoviedb://456".to_string()) },
|
||||
PlexGuidDto { id: Some("com.plexapp.agents.thetvdb://789?lang=en".to_string()) },
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
hints.iter().map(|hint| (hint.namespace.as_ref(), hint.value.as_ref())).collect::<Vec<_>>(),
|
||||
vec![
|
||||
("tmdb", "123"),
|
||||
("imdb", "tt-redacted"),
|
||||
("tmdb", "456"),
|
||||
("tvdb", "789")
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
pub mod client;
|
||||
pub mod dto;
|
||||
pub mod mapper;
|
||||
|
||||
@@ -55,11 +55,50 @@ pub const PLEX_SECTIONS_XML: &str = r#"
|
||||
|
||||
pub const PLEX_MOVIES_XML: &str = r#"
|
||||
<MediaContainer size="1" totalSize="1">
|
||||
<Video ratingKey="rating-redacted-1" key="/library/metadata/rating-redacted-1" type="movie" title="Movie Redacted" year="2024" thumb="/library/metadata/rating-redacted-1/thumb" addedAt="1700000000" updatedAt="1700000001">
|
||||
<Video ratingKey="rating-redacted-1" key="/library/metadata/rating-redacted-1" type="movie" title="Movie Redacted" titleSort="Movie Redacted" originalTitle="Original Movie Redacted" year="2024" originallyAvailableAt="2024-01-02" summary="Movie summary redacted" tagline="Tagline redacted" studio="Studio Redacted" contentRating="PG-13" contentRatingAge="13" audienceRating="8.2" thumb="/library/metadata/rating-redacted-1/thumb" art="/library/metadata/rating-redacted-1/art" addedAt="1700000000" updatedAt="1700000001">
|
||||
<Guid id="tmdb://12345" />
|
||||
<Media id="media-redacted-1" container="mkv" duration="7200000" bitrate="8000" width="1920" height="1080">
|
||||
<Guid id="imdb://tt-redacted" />
|
||||
<Genre tag="Drama" />
|
||||
<Country tag="Country Redacted" />
|
||||
<Director tag="Director Redacted" />
|
||||
<Writer tag="Writer Redacted" />
|
||||
<Role tag="Actor Redacted" />
|
||||
<Image type="coverPoster" url="/library/metadata/rating-redacted-1/thumb" />
|
||||
<Media id="media-redacted-1" container="mkv" duration="7200000" bitrate="8000" width="1920" height="1080" videoCodec="hevc" audioCodec="eac3" audioChannels="6" videoResolution="1080">
|
||||
<Part id="part-redacted" key="/library/parts/part-redacted/file.mkv" file="/redacted/upstream/path/movie.mkv" size="1024" container="mkv" />
|
||||
</Media>
|
||||
</Video>
|
||||
</MediaContainer>
|
||||
"#;
|
||||
|
||||
pub const PLEX_SHOWS_XML: &str = r#"
|
||||
<MediaContainer size="1" totalSize="1">
|
||||
<Directory ratingKey="series-redacted-1" key="/library/metadata/series-redacted-1/children" type="show" title="Show Redacted" titleSort="Show Redacted" originalTitle="Original Show Redacted" year="2024" originallyAvailableAt="2024-01-01" summary="Show summary redacted" tagline="Show tagline redacted" studio="Network Redacted" contentRating="TV-14" contentRatingAge="14" audienceRating="7.8" thumb="/library/metadata/series-redacted-1/thumb" art="/library/metadata/series-redacted-1/art" theme="/library/metadata/series-redacted-1/theme" childCount="1" leafCount="2" viewedLeafCount="0" addedAt="1700000100" updatedAt="1700000101">
|
||||
<Guid id="tmdb://222" />
|
||||
<Guid id="tvdb://333" />
|
||||
<Genre tag="Mystery" />
|
||||
<Country tag="Country Redacted" />
|
||||
<Role tag="Actor Redacted" />
|
||||
<Image type="coverPoster" url="/library/metadata/series-redacted-1/thumb" />
|
||||
</Directory>
|
||||
</MediaContainer>
|
||||
"#;
|
||||
|
||||
pub const PLEX_SEASONS_XML: &str = r#"
|
||||
<MediaContainer size="1" totalSize="1">
|
||||
<Directory ratingKey="season-redacted-1" key="/library/metadata/season-redacted-1/children" type="season" title="Season 1" index="1" parentRatingKey="series-redacted-1" parentGuid="tmdb://222" parentTitle="Show Redacted" parentIndex="1" year="2024" summary="Season summary redacted" thumb="/library/metadata/season-redacted-1/thumb" art="/library/metadata/season-redacted-1/art" leafCount="2" viewedLeafCount="0" addedAt="1700000200" updatedAt="1700000201">
|
||||
<Guid id="tvdb://333" />
|
||||
<Image type="coverPoster" url="/library/metadata/season-redacted-1/thumb" />
|
||||
</Directory>
|
||||
</MediaContainer>
|
||||
"#;
|
||||
|
||||
pub const PLEX_EPISODES_XML: &str = r#"
|
||||
<MediaContainer size="1" totalSize="1">
|
||||
<Video ratingKey="episode-redacted-1" key="/library/metadata/episode-redacted-1" type="episode" title="Episode Redacted" originallyAvailableAt="2024-02-03" summary="Episode summary redacted" guid="tmdb://67890" grandparentRatingKey="series-redacted-1" grandparentGuid="tmdb://222" grandparentTitle="Show Redacted" parentRatingKey="season-redacted-1" parentGuid="tvdb://333" parentTitle="Season 1" parentIndex="1" index="2" thumb="/library/metadata/episode-redacted-1/thumb" addedAt="1700000300" updatedAt="1700000301">
|
||||
<Media id="episode-media-redacted-1" container="mkv" duration="3600000" bitrate="4500" width="1280" height="720" videoCodec="h264" audioCodec="aac" audioChannels="2" videoResolution="720">
|
||||
<Part id="episode-part-redacted" key="/library/parts/episode-part-redacted/file.mkv" file="/redacted/upstream/path/episode.mkv" size="2048" container="mkv" />
|
||||
</Media>
|
||||
</Video>
|
||||
</MediaContainer>
|
||||
"#;
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::media_server::MediaServerError;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::BoxStream;
|
||||
use reqwest::{header::HeaderMap, StatusCode};
|
||||
use shared::model::InputType;
|
||||
use shared::model::{InputType, MediaServerLibraryKind};
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
|
||||
@@ -46,13 +46,6 @@ pub struct MediaServerStatus {
|
||||
pub owned: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum MediaServerLibraryKind {
|
||||
Movies,
|
||||
TvShows,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MediaServerLibraryRef {
|
||||
pub input_name: Arc<str>,
|
||||
|
||||
@@ -102,10 +102,7 @@ impl From<&ApiProxyConfig> for ApiProxyConfigDto {
|
||||
|
||||
fn serialize_api_proxy_config(config: &ApiProxyConfigDto) -> Result<String, String> {
|
||||
let mut serialized = String::new();
|
||||
let options = serde_saphyr::SerializerOptions {
|
||||
prefer_block_scalars: false,
|
||||
..Default::default()
|
||||
};
|
||||
let options = serde_saphyr::ser_options! {prefer_block_scalars: false};
|
||||
serde_saphyr::to_fmt_writer_with_options(&mut serialized, config, options)
|
||||
.map_err(|err| format!("Could not serialize api proxy config: {err}"))?;
|
||||
Ok(serialized)
|
||||
|
||||
@@ -5,7 +5,7 @@ use log::warn;
|
||||
use shared::foundation::Filter;
|
||||
use shared::{apply_flags, create_bitset};
|
||||
use shared::error::TuliproxError;
|
||||
use shared::model::{ClusterSource, ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, InputFetchMethod, InputType, MediaServerCatalogConfigDto, MediaServerImagePolicyDto, MediaServerInputConfigDto, MediaServerLibrarySelectorDto, MediaServerPlaybackConfigDto, StagedInputDto, XtreamCluster};
|
||||
use shared::model::{ClusterSource, ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, InputFetchMethod, InputType, MediaServerCatalogConfigDto, MediaServerImagePolicy, MediaServerInputConfigDto, MediaServerLibrarySelector, MediaServerPlaybackConfigDto, StagedInputDto, XtreamCluster};
|
||||
use shared::utils::{
|
||||
get_credentials_from_url, is_non_blank_optional_string, parse_provider_scheme_url_parts, sanitize_sensitive_info, Internable,
|
||||
BATCH_SCHEME_PREFIX, PROVIDER_SCHEME_PREFIX,
|
||||
@@ -104,16 +104,15 @@ static DEFAULT_CONFIG_INPUT_OPTIONS: LazyLock<ConfigInputOptions> =
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MediaServerInputConfig {
|
||||
pub libraries: Vec<MediaServerLibrarySelectorDto>,
|
||||
pub libraries: Vec<MediaServerLibrarySelector>,
|
||||
pub catalog: MediaServerCatalogConfigDto,
|
||||
pub playback: MediaServerPlaybackConfigDto,
|
||||
pub image_policy: MediaServerImagePolicyDto,
|
||||
pub image_policy: MediaServerImagePolicy,
|
||||
pub token: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
pub account_token: Option<String>,
|
||||
pub server_id: Option<String>,
|
||||
pub machine_id: Option<String>,
|
||||
pub server_name: Option<String>,
|
||||
pub prefer_https: bool,
|
||||
pub allow_relay: bool,
|
||||
@@ -133,7 +132,6 @@ impl From<&MediaServerInputConfigDto> for MediaServerInputConfig {
|
||||
user_id: normalized.user_id,
|
||||
account_token: normalized.account_token,
|
||||
server_id: normalized.server_id,
|
||||
machine_id: normalized.machine_id,
|
||||
server_name: normalized.server_name,
|
||||
prefer_https: normalized.prefer_https,
|
||||
allow_relay: normalized.allow_relay,
|
||||
@@ -151,9 +149,7 @@ impl MediaServerInputConfig {
|
||||
}
|
||||
|
||||
pub fn has_plex_server_selector(&self) -> bool {
|
||||
is_non_blank_optional_string(&self.server_id)
|
||||
|| is_non_blank_optional_string(&self.machine_id)
|
||||
|| is_non_blank_optional_string(&self.server_name)
|
||||
is_non_blank_optional_string(&self.server_id) || is_non_blank_optional_string(&self.server_name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,7 +537,7 @@ impl ConfigInput {
|
||||
self.name
|
||||
)));
|
||||
}
|
||||
if media_server.libraries.iter().any(MediaServerLibrarySelectorDto::is_empty) {
|
||||
if media_server.libraries.iter().any(MediaServerLibrarySelector::is_empty) {
|
||||
return Err(TuliproxError::ConfigInput(format!(
|
||||
"media_server library selectors must not be empty (input: {})",
|
||||
self.name
|
||||
@@ -571,15 +567,22 @@ impl ConfigInput {
|
||||
}
|
||||
}
|
||||
InputType::Plex => {
|
||||
if !media_server.has_any_plex_token() {
|
||||
if trimmed_url.is_empty() {
|
||||
if !is_non_blank_optional_string(&media_server.account_token) {
|
||||
return Err(TuliproxError::ConfigInput(format!(
|
||||
"media-server input type plex without input.url requires media_server.account_token for MyPlex discovery (input: {})",
|
||||
self.name
|
||||
)));
|
||||
}
|
||||
if !media_server.has_plex_server_selector() {
|
||||
return Err(TuliproxError::ConfigInput(format!(
|
||||
"media-server input type plex requires a server selector such as media_server.server_id or media_server.server_name when input.url is omitted (input: {})",
|
||||
self.name
|
||||
)));
|
||||
}
|
||||
} else if !is_non_blank_optional_string(&media_server.token) {
|
||||
return Err(TuliproxError::ConfigInput(format!(
|
||||
"media-server input type plex requires media_server.account_token or media_server.token (input: {})",
|
||||
self.name
|
||||
)));
|
||||
}
|
||||
if trimmed_url.is_empty() && !media_server.has_plex_server_selector() {
|
||||
return Err(TuliproxError::ConfigInput(format!(
|
||||
"media-server input type plex requires a server selector such as media_server.machine_id, media_server.server_id, or media_server.server_name when input.url is omitted (input: {})",
|
||||
"media-server input type plex with input.url requires media_server.token for direct PMS access (input: {})",
|
||||
self.name
|
||||
)));
|
||||
}
|
||||
@@ -913,13 +916,13 @@ fn assemble_provider_url_at_index(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::ConfigProvider;
|
||||
use shared::model::{ConfigProviderDto, MediaServerInputConfigDto, MediaServerLibrarySelectorDto, ProviderUrlSelectionPolicy};
|
||||
use shared::model::{ConfigProviderDto, MediaServerInputConfigDto, MediaServerLibrarySelector, ProviderUrlSelectionPolicy};
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn media_server_config_with_library() -> MediaServerInputConfig {
|
||||
MediaServerInputConfig::from(&MediaServerInputConfigDto {
|
||||
libraries: vec![MediaServerLibrarySelectorDto::Name("Movies".to_string())],
|
||||
libraries: vec![MediaServerLibrarySelector::Name("Movies".to_string())],
|
||||
..MediaServerInputConfigDto::default()
|
||||
})
|
||||
}
|
||||
@@ -936,10 +939,9 @@ mod tests {
|
||||
user_id: Some(" user ".to_string()),
|
||||
account_token: Some(" account-token ".to_string()),
|
||||
server_id: Some(" server ".to_string()),
|
||||
machine_id: Some(" machine ".to_string()),
|
||||
server_name: Some(" server-name ".to_string()),
|
||||
..MediaServerInputConfigDto {
|
||||
libraries: vec![MediaServerLibrarySelectorDto::Name(" Movies ".to_string())],
|
||||
libraries: vec![MediaServerLibrarySelector::Name(" Movies ".to_string())],
|
||||
..MediaServerInputConfigDto::default()
|
||||
}
|
||||
}),
|
||||
@@ -949,7 +951,7 @@ mod tests {
|
||||
let input = ConfigInput::from(&dto);
|
||||
let media_server = input.media_server.expect("media_server config should map to runtime");
|
||||
|
||||
assert_eq!(media_server.libraries, vec![MediaServerLibrarySelectorDto::Name("Movies".to_string())]);
|
||||
assert_eq!(media_server.libraries, vec![MediaServerLibrarySelector::Name("Movies".to_string())]);
|
||||
assert_eq!(media_server.catalog.page_size, 100);
|
||||
assert!(media_server.playback.direct_play_only);
|
||||
assert!(!media_server.playback.allow_transcode);
|
||||
@@ -958,7 +960,6 @@ mod tests {
|
||||
assert_eq!(media_server.user_id.as_deref(), Some("user"));
|
||||
assert_eq!(media_server.account_token.as_deref(), Some("account-token"));
|
||||
assert_eq!(media_server.server_id.as_deref(), Some("server"));
|
||||
assert_eq!(media_server.machine_id.as_deref(), Some("machine"));
|
||||
assert_eq!(media_server.server_name.as_deref(), Some("server-name"));
|
||||
}
|
||||
|
||||
@@ -969,7 +970,7 @@ mod tests {
|
||||
input_type: InputType::Plex,
|
||||
media_server: Some(MediaServerInputConfig {
|
||||
account_token: Some("token".to_string()),
|
||||
machine_id: Some("machine".to_string()),
|
||||
server_id: Some("server".to_string()),
|
||||
..media_server_config_with_library()
|
||||
}),
|
||||
enabled: true,
|
||||
@@ -996,6 +997,42 @@ mod tests {
|
||||
input.prepare(&[]).expect("direct Plex URL should not require MyPlex server selector");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_rejects_plex_direct_url_without_server_token() {
|
||||
let mut input = ConfigInput {
|
||||
name: "plex_media_server".into(),
|
||||
input_type: InputType::Plex,
|
||||
url: "https://plex.example.invalid".to_string(),
|
||||
media_server: Some(MediaServerInputConfig {
|
||||
account_token: Some("account-token".to_string()),
|
||||
..media_server_config_with_library()
|
||||
}),
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = input.prepare(&[]).expect_err("direct Plex URL requires the PMS server token");
|
||||
assert!(err.to_string().contains("requires media_server.token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_rejects_plex_discovery_without_account_token() {
|
||||
let mut input = ConfigInput {
|
||||
name: "plex_media_server".into(),
|
||||
input_type: InputType::Plex,
|
||||
media_server: Some(MediaServerInputConfig {
|
||||
token: Some("server-token".to_string()),
|
||||
server_id: Some("server".to_string()),
|
||||
..media_server_config_with_library()
|
||||
}),
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = input.prepare(&[]).expect_err("Plex discovery requires the MyPlex account token");
|
||||
assert!(err.to_string().contains("requires media_server.account_token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_rejects_emby_media_server_without_input_url() {
|
||||
let mut input = ConfigInput {
|
||||
@@ -1042,7 +1079,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let err = plex.prepare(&[]).expect_err("blank plex token should be rejected");
|
||||
assert!(err.to_string().contains("requires media_server.account_token or media_server.token"));
|
||||
assert!(err.to_string().contains("requires media_server.account_token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1053,7 +1090,7 @@ mod tests {
|
||||
url: "https://media.example.invalid".to_string(),
|
||||
media_server: Some(MediaServerInputConfig {
|
||||
token: Some("token".to_string()),
|
||||
libraries: vec![MediaServerLibrarySelectorDto::Name(" ".to_string())],
|
||||
libraries: vec![MediaServerLibrarySelector::Name(" ".to_string())],
|
||||
..media_server_config_with_library()
|
||||
}),
|
||||
enabled: true,
|
||||
|
||||
@@ -9,8 +9,8 @@ use shared::model::{
|
||||
XtreamMappingOptions,
|
||||
};
|
||||
use shared::utils::{arc_str_serde, concat_path_leading_slash, deserialize_number_from_string_or_zero};
|
||||
use enum_iterator::all;
|
||||
use crate::model::XtreamTargetFlags;
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct XtreamCategory {
|
||||
@@ -38,7 +38,7 @@ pub fn xtream_mapping_option_from_target_options(target: &ConfigTarget, target_o
|
||||
let mut reverse_item_types = PlaylistItemTypeSet::empty();
|
||||
let mut resource_proxy_item_types = PlaylistItemTypeSet::empty();
|
||||
|
||||
for item_type in all::<PlaylistItemType>() {
|
||||
for item_type in PlaylistItemType::iter() {
|
||||
if user.proxy.is_reverse(item_type) && !force_redirect.as_ref().is_some_and(|o| o.has_cluster(item_type)) {
|
||||
reverse_item_types.insert(item_type);
|
||||
}
|
||||
|
||||
@@ -250,6 +250,7 @@ fn build_series_episode_playlist_item(
|
||||
added: Some(episode.added.clone()),
|
||||
release_date: Some(episode.release_date.clone()),
|
||||
series_release_date: None,
|
||||
plot: episode.plot.clone(),
|
||||
tmdb: episode.tmdb,
|
||||
movie_image: logo,
|
||||
container_extension: container_extension.intern(),
|
||||
|
||||
@@ -8,6 +8,11 @@ use crate::{
|
||||
sync_panel_api_exp_dates,
|
||||
},
|
||||
messaging::send_message,
|
||||
media_server::{
|
||||
media_server_catalog_snapshot_to_playlist, refresh_media_server_catalog_complete_before_publish,
|
||||
MediaServerCatalogRefreshPolicy, MediaServerHttpClient,
|
||||
},
|
||||
media_server::plex::client::PlexCatalogClient,
|
||||
model::{
|
||||
AppConfig, ConfigFavourites, ConfigInput, ConfigInputFlags, ConfigInputOptions, ConfigRename, ConfigTarget,
|
||||
FetchedPlaylist, Mapping, MessageContent, ProcessTargets, ReverseProxyDisabledHeaderConfig, TVGuide,
|
||||
@@ -362,6 +367,35 @@ fn hybrid_has_m3u_staged_cluster(input: &ConfigInput, skip_cluster: &[XtreamClus
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_plex_media_server_playlist(
|
||||
client: &reqwest::Client,
|
||||
input: &ConfigInput,
|
||||
) -> (Vec<PlaylistGroup>, Vec<TuliproxError>) {
|
||||
let Some(media_server) = input.media_server.as_ref() else {
|
||||
return (
|
||||
vec![],
|
||||
vec![TuliproxError::Download(format!(
|
||||
"media-server input '{}' is missing media_server configuration",
|
||||
input.name
|
||||
))],
|
||||
);
|
||||
};
|
||||
let http_client = MediaServerHttpClient::new(client.clone());
|
||||
let plex_client = match PlexCatalogClient::from_input(input, http_client) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return (vec![], vec![TuliproxError::Download(error.to_string())]),
|
||||
};
|
||||
let policy = MediaServerCatalogRefreshPolicy {
|
||||
page_size: usize::from(media_server.catalog.page_size),
|
||||
request_delay_ms: media_server.catalog.request_delay_ms,
|
||||
};
|
||||
|
||||
match refresh_media_server_catalog_complete_before_publish(&plex_client, policy).await {
|
||||
Ok(snapshot) => (media_server_catalog_snapshot_to_playlist(&snapshot), vec![]),
|
||||
Err(error) => (vec![], vec![TuliproxError::Download(error.to_string())]),
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_skipped_clusters_from_source(
|
||||
source: Box<dyn PlaylistSource>,
|
||||
input: &ConfigInput,
|
||||
@@ -418,7 +452,7 @@ async fn playlist_download_from_input(
|
||||
xtream_cache_candidates.extend(main_candidates);
|
||||
|
||||
for cluster in xtream_cache_candidates {
|
||||
if !input_cache::is_cache_valid(&status, &cluster.to_string(), cache_duration) {
|
||||
if !input_cache::is_cache_valid(&status, cluster.as_ref(), cache_duration) {
|
||||
xtream_clusters_to_download.push(cluster);
|
||||
}
|
||||
}
|
||||
@@ -507,7 +541,11 @@ async fn playlist_download_from_input(
|
||||
let (p, e) = library::download_library_playlist(client, app_config, input).await;
|
||||
(p, e, false, 0, 0)
|
||||
}
|
||||
InputType::Emby | InputType::Jellyfin | InputType::Plex => (
|
||||
InputType::Plex => {
|
||||
let (p, e) = download_plex_media_server_playlist(client, input).await;
|
||||
(p, e, false, 0, 0)
|
||||
}
|
||||
InputType::Emby | InputType::Jellyfin => (
|
||||
vec![],
|
||||
vec![TuliproxError::Download(format!(
|
||||
"media-server input '{}' is configured but catalog import is not implemented yet",
|
||||
@@ -532,14 +570,14 @@ async fn playlist_download_from_input(
|
||||
if !xtream_clusters_to_download.is_empty() {
|
||||
let state = if xtream_error_count == 0 { ClusterState::Ok } else { ClusterState::Failed };
|
||||
for cluster in &xtream_clusters_to_download {
|
||||
input_cache::update_cluster_status(&mut status, &cluster.to_string(), state.clone());
|
||||
input_cache::update_cluster_status(&mut status, cluster.as_ref(), state.clone());
|
||||
}
|
||||
save_status = true;
|
||||
}
|
||||
} else if errors.is_empty() {
|
||||
if use_per_cluster_cache {
|
||||
for cluster in &xtream_clusters_to_download {
|
||||
input_cache::update_cluster_status(&mut status, &cluster.to_string(), ClusterState::Ok);
|
||||
input_cache::update_cluster_status(&mut status, cluster.as_ref(), ClusterState::Ok);
|
||||
}
|
||||
save_status = !xtream_clusters_to_download.is_empty();
|
||||
} else {
|
||||
@@ -548,7 +586,7 @@ async fn playlist_download_from_input(
|
||||
}
|
||||
} else if use_per_cluster_cache {
|
||||
for cluster in &xtream_clusters_to_download {
|
||||
input_cache::update_cluster_status(&mut status, &cluster.to_string(), ClusterState::Failed);
|
||||
input_cache::update_cluster_status(&mut status, cluster.as_ref(), ClusterState::Failed);
|
||||
}
|
||||
save_status = !xtream_clusters_to_download.is_empty();
|
||||
} else {
|
||||
|
||||
@@ -167,7 +167,7 @@ fn build_rule_cache_entry(
|
||||
return RuleCacheEntry { filter_pass: false, value: None, sequence_match: None };
|
||||
}
|
||||
|
||||
let value = provider.get(rule.rule.field.as_str());
|
||||
let value = provider.get(rule.rule.field.as_ref());
|
||||
let sequence_match = match (&rule.sequence_plan, value.as_deref()) {
|
||||
(Some(sequence_plan), Some(value)) => Some(evaluate_sequence(sequence_plan, value)),
|
||||
_ => None,
|
||||
|
||||
@@ -481,6 +481,7 @@ pub fn update_properties(
|
||||
added: None,
|
||||
release_date: None,
|
||||
series_release_date: None,
|
||||
plot: None,
|
||||
tmdb: None,
|
||||
movie_image: "".into(),
|
||||
container_extension: "".into(),
|
||||
|
||||
@@ -221,6 +221,7 @@ mod tests {
|
||||
added: added.map(Internable::intern),
|
||||
release_date: None,
|
||||
series_release_date: None,
|
||||
plot: None,
|
||||
tmdb: None,
|
||||
movie_image: "".intern(),
|
||||
container_extension: "".intern(),
|
||||
|
||||
@@ -232,32 +232,32 @@ fn media_server_series_episode_detail(header: &PlaylistItemHeader) -> Option<Ser
|
||||
return None;
|
||||
}
|
||||
|
||||
let episode = match header.additional_properties.as_ref() {
|
||||
Some(StreamProperties::Episode(episode)) => Some(episode.as_ref()),
|
||||
_ => None,
|
||||
let Some(StreamProperties::Episode(episode)) = header.additional_properties.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
let episode = episode.as_ref();
|
||||
|
||||
Some(SeriesStreamDetailEpisodeProperties {
|
||||
id: header.virtual_id,
|
||||
episode_num: episode.map_or(0, |ep| ep.episode),
|
||||
season: episode.map_or(0, |ep| ep.season),
|
||||
episode_num: episode.episode,
|
||||
season: episode.season,
|
||||
title: non_blank_arc(&header.title).unwrap_or_else(|| non_blank_arc(&header.name).unwrap_or_else(|| "Episode".intern())),
|
||||
container_extension: episode.map_or_else(|| "".intern(), |ep| ep.container_extension.clone()),
|
||||
container_extension: episode.container_extension.clone(),
|
||||
custom_sid: None,
|
||||
added: episode.and_then(|ep| ep.added.clone()).unwrap_or_else(|| "".intern()),
|
||||
added: episode.added.clone().unwrap_or_else(|| "".intern()),
|
||||
direct_source: "".intern(),
|
||||
tmdb: episode.and_then(|ep| ep.tmdb),
|
||||
release_date: episode.and_then(|ep| ep.release_date.clone()).unwrap_or_else(|| "".intern()),
|
||||
series_release_date: episode.and_then(|ep| ep.series_release_date.clone()),
|
||||
plot: None,
|
||||
tmdb: episode.tmdb,
|
||||
release_date: episode.release_date.clone().unwrap_or_else(|| "".intern()),
|
||||
series_release_date: episode.series_release_date.clone(),
|
||||
plot: episode.plot.clone(),
|
||||
crew: None,
|
||||
duration_secs: 0,
|
||||
duration: "".intern(),
|
||||
movie_image: "".intern(),
|
||||
movie_image: episode.movie_image.clone(),
|
||||
bitrate: 0,
|
||||
rating: None,
|
||||
video: episode.and_then(|ep| ep.video.clone()),
|
||||
audio: episode.and_then(|ep| ep.audio.clone()),
|
||||
video: episode.video.clone(),
|
||||
audio: episode.audio.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -756,8 +756,9 @@ mod tests {
|
||||
added: Some("1700000000".intern()),
|
||||
release_date: Some("2024-02-03".intern()),
|
||||
series_release_date: Some("2024-01-01".intern()),
|
||||
plot: Some("Episode summary".intern()),
|
||||
tmdb: Some(67890),
|
||||
movie_image: "".intern(),
|
||||
movie_image: "media-server://image/plex/server/episode?image_path=%2Flibrary%2Fmetadata%2Fredacted%2Fthumb".intern(),
|
||||
container_extension: "mkv".intern(),
|
||||
video: Some(r#"{"codec_name":"h264"}"#.intern()),
|
||||
audio: Some(r#"{"codec_name":"aac"}"#.intern()),
|
||||
@@ -803,10 +804,22 @@ mod tests {
|
||||
let series_parent_code = series_info.header.uuid.to_string();
|
||||
let media_episode_two = PlaylistItem { header: make_media_server_episode(&series_parent_code, "episode-two", 7002, 1, 2) };
|
||||
let media_episode_one = PlaylistItem { header: make_media_server_episode(&series_parent_code, "episode-one", 7001, 1, 1) };
|
||||
let malformed_media_episode = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: "media-server:server:shows:episode:malformed".intern(),
|
||||
parent_code: series_parent_code.clone().intern(),
|
||||
url: "media-server://plex/server/malformed?part_key=%2Flibrary%2Fparts%2Fredacted".intern(),
|
||||
item_type: PlaylistItemType::Series,
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
virtual_id: 7003,
|
||||
additional_properties: None,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
let provider_episode = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: "999".intern(),
|
||||
parent_code: series_parent_code.intern(),
|
||||
parent_code: series_parent_code.clone().intern(),
|
||||
url: "http://provider.example.invalid/series/999.mkv".intern(),
|
||||
item_type: PlaylistItemType::Series,
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
@@ -818,12 +831,13 @@ mod tests {
|
||||
let mut media_server_series = HashMap::<Arc<str>, Vec<SeriesStreamDetailEpisodeProperties>>::new();
|
||||
assign_media_server_series_info_episode(&mut media_server_series, &media_episode_two.header);
|
||||
assign_media_server_series_info_episode(&mut media_server_series, &provider_episode.header);
|
||||
assign_media_server_series_info_episode(&mut media_server_series, &malformed_media_episode.header);
|
||||
assign_media_server_series_info_episode(&mut media_server_series, &media_episode_one.header);
|
||||
|
||||
let mut playlist = vec![PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Media Server Series".intern(),
|
||||
channels: vec![series_info, media_episode_two, provider_episode, media_episode_one],
|
||||
channels: vec![series_info, media_episode_two, provider_episode, malformed_media_episode, media_episode_one],
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
}];
|
||||
|
||||
@@ -845,12 +859,17 @@ mod tests {
|
||||
assert_eq!(episodes[0].container_extension.as_ref(), "mkv");
|
||||
assert_eq!(episodes[0].release_date.as_ref(), "2024-02-03");
|
||||
assert_eq!(episodes[0].series_release_date.as_deref(), Some("2024-01-01"));
|
||||
assert_eq!(episodes[0].plot.as_deref(), Some("Episode summary"));
|
||||
assert_eq!(episodes[0].tmdb, Some(67890));
|
||||
assert_eq!(episodes[0].direct_source.as_ref(), "");
|
||||
assert_eq!(episodes[0].movie_image.as_ref(), "");
|
||||
assert_eq!(
|
||||
episodes[0].movie_image.as_ref(),
|
||||
"media-server://image/plex/server/episode?image_path=%2Flibrary%2Fmetadata%2Fredacted%2Fthumb"
|
||||
);
|
||||
assert!(episodes[0].video.as_deref().is_some_and(|video| video.contains("h264")));
|
||||
assert!(episodes[0].audio.as_deref().is_some_and(|audio| audio.contains("aac")));
|
||||
assert_eq!(episodes[1].id, 7002);
|
||||
assert!(!episodes.iter().any(|episode| episode.id == 7003));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -187,10 +187,7 @@ fn serialize_report_value(value: &Value, format: RuntimeConfigReportFormat) -> R
|
||||
.map_err(|err| TuliproxError::Config(format!("Failed to serialize runtime config report as JSON: {err}"))),
|
||||
RuntimeConfigReportFormat::Yaml => {
|
||||
let mut serialized = String::new();
|
||||
let options = serde_saphyr::SerializerOptions {
|
||||
prefer_block_scalars: false,
|
||||
..Default::default()
|
||||
};
|
||||
let options = serde_saphyr::ser_options! {prefer_block_scalars: false};
|
||||
serde_saphyr::to_fmt_writer_with_options(&mut serialized, value, options)
|
||||
.map_err(|err| TuliproxError::Config(format!("Failed to serialize runtime config report as YAML: {err}")))?;
|
||||
Ok(serialized)
|
||||
|
||||
@@ -746,10 +746,7 @@ where
|
||||
});
|
||||
|
||||
let mut serialized = String::new();
|
||||
let options = serde_saphyr::SerializerOptions {
|
||||
prefer_block_scalars: false,
|
||||
..Default::default()
|
||||
};
|
||||
let options = serde_saphyr::ser_options! {prefer_block_scalars: false};
|
||||
serde_saphyr::to_fmt_writer_with_options(&mut serialized, &config, options)
|
||||
.map_err(|err| TuliproxError::Config(format!("Could not serialize config: {err}")))?;
|
||||
|
||||
|
||||
+2
-1
@@ -35,7 +35,8 @@ cron.workspace = true
|
||||
brotli.workspace = true
|
||||
fastrand.workspace = true
|
||||
indexmap.workspace = true
|
||||
enum-iterator.workspace = true
|
||||
strum.workspace = true
|
||||
strum_macros.workspace = true
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3.91"
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::{
|
||||
};
|
||||
use shared::model::{LogConfigDto, RuntimeConfigReportFormat};
|
||||
use std::{rc::Rc, str::FromStr};
|
||||
use strum::IntoEnumIterator;
|
||||
use yew::prelude::*;
|
||||
|
||||
const LABEL_LOG_LEVEL: &str = "LABEL.LOG_LEVEL";
|
||||
@@ -45,11 +46,7 @@ pub fn LogConfigView() -> Html {
|
||||
|
||||
let log_level_options = use_memo((), |_| LOG_LEVELS.iter().map(ToString::to_string).collect::<Vec<String>>());
|
||||
let runtime_report_format_options = use_memo((), |_| {
|
||||
enum_iterator::all::<RuntimeConfigReportFormat>()
|
||||
.collect::<Vec<_>>()
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<String>>()
|
||||
RuntimeConfigReportFormat::iter().collect::<Vec<_>>().iter().map(ToString::to_string).collect::<Vec<String>>()
|
||||
});
|
||||
|
||||
let form_state: UseReducerHandle<LogConfigFormState> =
|
||||
|
||||
@@ -21,7 +21,6 @@ use crate::{
|
||||
edit_field_number_u64, edit_field_number_usize, edit_field_text, edit_field_text_option, generate_form_reducer,
|
||||
i18n::{use_translation, YewI18n},
|
||||
};
|
||||
use enum_iterator::all;
|
||||
use shared::{
|
||||
model::{
|
||||
CacheConfigDto, GeoIpConfigDto, GeoIpUnavailablePolicy, QosAggregationConfigDto, RateLimitConfigDto,
|
||||
@@ -31,7 +30,9 @@ use shared::{
|
||||
utils::{default_secret, format_float_localized},
|
||||
};
|
||||
use std::{rc::Rc, str::FromStr};
|
||||
use strum::IntoEnumIterator;
|
||||
use yew::prelude::*;
|
||||
|
||||
const LABEL_CACHE: &str = "LABEL.CACHE";
|
||||
const LABEL_ENABLED: &str = "LABEL.ENABLED";
|
||||
const LABEL_SIZE: &str = "LABEL.SIZE";
|
||||
@@ -219,7 +220,7 @@ generate_form_reducer!(
|
||||
);
|
||||
|
||||
fn geoip_unavailable_policy_options() -> Rc<Vec<String>> {
|
||||
Rc::new(all::<GeoIpUnavailablePolicy>().map(|policy| policy.to_string()).collect())
|
||||
Rc::new(GeoIpUnavailablePolicy::iter().map(|policy| policy.to_string()).collect())
|
||||
}
|
||||
|
||||
pub(crate) fn geoip_unavailable_policy_label(translate: &YewI18n, policy: GeoIpUnavailablePolicy) -> String {
|
||||
@@ -1006,8 +1007,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn geoip_unavailable_policy_roundtrips_through_string_representation() {
|
||||
for policy in all::<GeoIpUnavailablePolicy>() {
|
||||
let parsed = GeoIpUnavailablePolicy::from_str(&policy.to_string()).unwrap_or(GeoIpUnavailablePolicy::Deny);
|
||||
for policy in GeoIpUnavailablePolicy::iter() {
|
||||
let parsed =
|
||||
GeoIpUnavailablePolicy::from_str(policy.as_ref()).expect("failed to parse GeoIpUnavailablePolicy");
|
||||
assert_eq!(parsed, policy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::{
|
||||
use shared::model::{
|
||||
view_type::ViewType, ContentSecurityPolicyConfigDto, StreamInfoConfigDto, WebAuthConfigDto, WebUiConfigDto,
|
||||
};
|
||||
use strum::IntoEnumIterator;
|
||||
use yew::prelude::*;
|
||||
|
||||
// Labels
|
||||
@@ -119,7 +120,7 @@ pub fn WebUiConfigView() -> Html {
|
||||
use_reducer(|| StreamInfoConfigFormState { form: StreamInfoConfigDto::default(), modified: false });
|
||||
|
||||
let view_types = use_memo(webui_state.data().landing_page, |landing_page| {
|
||||
enum_iterator::all::<ViewType>()
|
||||
ViewType::iter()
|
||||
.collect::<Vec<_>>()
|
||||
.iter()
|
||||
.map(|view_type| DropDownOption {
|
||||
|
||||
@@ -33,7 +33,7 @@ pub fn TargetSort(props: &TargetSortProps) -> Html {
|
||||
</div>
|
||||
<div class="tp__target-sort__section tp__target-sort__row">
|
||||
<span class="tp__target-sort__label">{ translator.t("LABEL.FIELD") }</span>
|
||||
<span>{ rule.field.as_str() }</span>
|
||||
<span>{ rule.field.to_string() }</span>
|
||||
</div>
|
||||
<div class="tp__target-sort__section tp__target-sort__row">
|
||||
<span class="tp__target-sort__label">{ translator.t("LABEL.ORDER") }</span>
|
||||
|
||||
@@ -22,6 +22,7 @@ use shared::{
|
||||
utils::generate_random_string,
|
||||
};
|
||||
use std::{net::IpAddr, rc::Rc};
|
||||
use strum::IntoEnumIterator;
|
||||
use yew::prelude::*;
|
||||
|
||||
const DEFAULT_MAX_CONNECTIONS: u32 = 1;
|
||||
@@ -138,7 +139,7 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
|
||||
use_reducer(|| UserFormState { form: ProxyUserCredentialsDto::default(), modified: false });
|
||||
|
||||
let proxy_user_status = use_memo(form_state.data().status, |status| {
|
||||
enum_iterator::all::<ProxyUserStatus>()
|
||||
ProxyUserStatus::iter()
|
||||
.map(|s| DropDownOption {
|
||||
id: s.to_string(),
|
||||
label: html! { <UserStatus status={Some(s)} /> },
|
||||
|
||||
+5
-4
@@ -6,9 +6,11 @@ rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bitflags = "2.11.0"
|
||||
ciborium = "0.2.2"
|
||||
hex = "0.4.3"
|
||||
serde_tuple = "1.1.3"
|
||||
serde.workspace = true
|
||||
regex.workspace = true
|
||||
enum-iterator.workspace = true
|
||||
log.workspace = true
|
||||
path-clean.workspace = true
|
||||
url.workspace = true
|
||||
@@ -22,16 +24,15 @@ fastrand = { workspace = true, features = ["js"] }
|
||||
zeroize.workspace = true
|
||||
chrono.workspace = true
|
||||
bytes.workspace = true
|
||||
ciborium = "0.2.2"
|
||||
hex = "0.4.3"
|
||||
lz4_flex.workspace = true
|
||||
brotli.workspace = true
|
||||
deunicode.workspace = true
|
||||
serde-saphyr.workspace = true
|
||||
dashmap.workspace = true
|
||||
serde_tuple = "1.1.3"
|
||||
paste.workspace = true
|
||||
thiserror.workspace = true
|
||||
strum_macros.workspace = true
|
||||
strum.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = "0.3.91"
|
||||
|
||||
@@ -6,7 +6,6 @@ use crate::{
|
||||
foundation::value_provider::ValueProvider,
|
||||
utils::{DirectedGraph, Internable, CONSTANTS},
|
||||
};
|
||||
use enum_iterator::all;
|
||||
use indexmap::IndexSet;
|
||||
use log::{error, log_enabled, trace, Level};
|
||||
use pest::{iterators::Pair, Parser};
|
||||
@@ -16,6 +15,7 @@ use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledRegex {
|
||||
@@ -121,7 +121,7 @@ impl Filter {
|
||||
Self::FieldComparison(field, rewc) => {
|
||||
let (is_match, value) = if field == &ItemField::Caption {
|
||||
get_caption(provider, rewc)
|
||||
} else if let Some(value) = provider.get(field.as_str()) {
|
||||
} else if let Some(value) = provider.get(field.as_ref()) {
|
||||
(rewc.re.is_match(&value), value)
|
||||
} else {
|
||||
(false, "".intern())
|
||||
@@ -136,7 +136,7 @@ impl Filter {
|
||||
is_match
|
||||
}
|
||||
Self::TypeComparison(field, item_type) => {
|
||||
if let Some(value) = provider.get(field.as_str()) {
|
||||
if let Some(value) = provider.get(field.as_ref()) {
|
||||
get_filter_item_type(&value).is_some_and(|pli_type| {
|
||||
let is_match = match item_type {
|
||||
PlaylistItemType::Video => {
|
||||
@@ -221,7 +221,7 @@ impl std::fmt::Display for Filter {
|
||||
fn get_parser_item_field(expr: &Pair<Rule>) -> Result<ItemField, TuliproxError> {
|
||||
if expr.as_rule() == Rule::field {
|
||||
let field_text = expr.as_str();
|
||||
for item in all::<ItemField>() {
|
||||
for item in ItemField::iter() {
|
||||
if field_text.eq_ignore_ascii_case(item.to_string().as_str()) {
|
||||
return Ok(item);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use crate::error::TuliproxError;
|
||||
use enum_iterator::Sequence;
|
||||
use std::fmt;
|
||||
use strum_macros::{AsRefStr, Display, EnumIter, EnumString};
|
||||
|
||||
pub fn default_geoip_url() -> String {
|
||||
String::from(
|
||||
@@ -8,7 +6,21 @@ pub fn default_geoip_url() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default, Sequence)]
|
||||
#[derive(
|
||||
Debug,
|
||||
Copy,
|
||||
Clone,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Default,
|
||||
EnumIter,
|
||||
EnumString,
|
||||
AsRefStr,
|
||||
Display,
|
||||
)]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum GeoIpUnavailablePolicy {
|
||||
#[default]
|
||||
@@ -16,26 +28,6 @@ pub enum GeoIpUnavailablePolicy {
|
||||
Allow,
|
||||
}
|
||||
|
||||
impl fmt::Display for GeoIpUnavailablePolicy {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
GeoIpUnavailablePolicy::Deny => write!(f, "deny"),
|
||||
GeoIpUnavailablePolicy::Allow => write!(f, "allow"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for GeoIpUnavailablePolicy {
|
||||
type Err = TuliproxError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"deny" => Ok(GeoIpUnavailablePolicy::Deny),
|
||||
"allow" => Ok(GeoIpUnavailablePolicy::Allow),
|
||||
_ => Err(TuliproxError::Config(format!("Unknown GeoIpUnavailablePolicy {s}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn is_default_unavailable_policy(policy: &GeoIpUnavailablePolicy) -> bool {
|
||||
matches!(policy, GeoIpUnavailablePolicy::Deny)
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ use crate::{
|
||||
arc_str_serde, arc_str_vec_serde, default_as_true, default_probe_delay_secs, default_probe_live_interval,
|
||||
default_resolve_background, default_resolve_delay_secs, default_xtream_live_stream_use_prefix,
|
||||
deserialize_timestamp, get_credentials_from_url_str, get_trimmed_string, is_blank_optional_string,
|
||||
is_default_probe_delay_secs, is_default_probe_live_interval, is_default_resolve_delay_secs, is_false, is_true,
|
||||
is_zero_i16, is_zero_u16, parse_duration_seconds, parse_provider_scheme_url_parts, sanitize_sensitive_info,
|
||||
serialize_option_vec_flow_map_items, trim_last_slash, Internable, BATCH_SCHEME_PREFIX, PROVIDER_SCHEME_PREFIX,
|
||||
is_default_probe_delay_secs, is_default_probe_live_interval, is_default_resolve_delay_secs, is_false,
|
||||
is_non_blank_optional_string, is_true, is_zero_i16, is_zero_u16, parse_duration_seconds,
|
||||
parse_provider_scheme_url_parts, sanitize_sensitive_info, serialize_option_vec_flow_map_items, trim_last_slash,
|
||||
Internable, BATCH_SCHEME_PREFIX, PROVIDER_SCHEME_PREFIX,
|
||||
},
|
||||
};
|
||||
use enum_iterator::Sequence;
|
||||
use log::warn;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
@@ -22,6 +22,7 @@ use std::{
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
};
|
||||
use strum_macros::{AsRefStr, Display, EnumIter, EnumString};
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! apply_batch_aliases {
|
||||
@@ -81,36 +82,35 @@ macro_rules! check_provider_scheme_url {
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)]
|
||||
#[derive(
|
||||
Debug,
|
||||
Copy,
|
||||
Clone,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Default,
|
||||
EnumIter,
|
||||
Display,
|
||||
EnumString,
|
||||
AsRefStr,
|
||||
)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InputType {
|
||||
#[serde(rename = "m3u")]
|
||||
#[default]
|
||||
M3u,
|
||||
#[serde(rename = "xtream")]
|
||||
Xtream,
|
||||
#[serde(rename = "m3u_batch")]
|
||||
M3uBatch,
|
||||
#[serde(rename = "xtream_batch")]
|
||||
XtreamBatch,
|
||||
#[serde(rename = "library")]
|
||||
Library,
|
||||
#[serde(rename = "emby")]
|
||||
Emby,
|
||||
#[serde(rename = "jellyfin")]
|
||||
Jellyfin,
|
||||
#[serde(rename = "plex")]
|
||||
Plex,
|
||||
}
|
||||
|
||||
impl InputType {
|
||||
const M3U: &'static str = "m3u";
|
||||
const XTREAM: &'static str = "xtream";
|
||||
const M3U_BATCH: &'static str = "m3u_batch";
|
||||
const XTREAM_BATCH: &'static str = "xtream_batch";
|
||||
const LIBRARY: &'static str = "library";
|
||||
const EMBY: &'static str = "emby";
|
||||
const JELLYFIN: &'static str = "jellyfin";
|
||||
const PLEX: &'static str = "plex";
|
||||
pub fn is_xtream(&self) -> bool { matches!(self, Self::Xtream | Self::XtreamBatch) }
|
||||
pub fn is_m3u(&self) -> bool { matches!(self, Self::M3u | Self::M3uBatch) }
|
||||
pub fn uses_standard_input_url(&self) -> bool {
|
||||
@@ -121,52 +121,22 @@ impl InputType {
|
||||
pub fn is_media_server(&self) -> bool { matches!(self, Self::Emby | Self::Jellyfin | Self::Plex) }
|
||||
}
|
||||
|
||||
impl Display for InputType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
Self::M3u => Self::M3U,
|
||||
Self::Xtream => Self::XTREAM,
|
||||
Self::M3uBatch => Self::M3U_BATCH,
|
||||
Self::XtreamBatch => Self::XTREAM_BATCH,
|
||||
Self::Library => Self::LIBRARY,
|
||||
Self::Emby => Self::EMBY,
|
||||
Self::Jellyfin => Self::JELLYFIN,
|
||||
Self::Plex => Self::PLEX,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for InputType {
|
||||
type Err = TuliproxError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, TuliproxError> {
|
||||
if s.eq(Self::M3U) {
|
||||
Ok(Self::M3u)
|
||||
} else if s.eq(Self::XTREAM) {
|
||||
Ok(Self::Xtream)
|
||||
} else if s.eq(Self::M3U_BATCH) {
|
||||
Ok(Self::M3uBatch)
|
||||
} else if s.eq(Self::XTREAM_BATCH) {
|
||||
Ok(Self::XtreamBatch)
|
||||
} else if s.eq(Self::LIBRARY) {
|
||||
Ok(Self::Library)
|
||||
} else if s.eq(Self::EMBY) {
|
||||
Ok(Self::Emby)
|
||||
} else if s.eq(Self::JELLYFIN) {
|
||||
Ok(Self::Jellyfin)
|
||||
} else if s.eq(Self::PLEX) {
|
||||
Ok(Self::Plex)
|
||||
} else {
|
||||
Err(TuliproxError::ConfigInput(format!("Unknown InputType: {}", s)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)]
|
||||
#[derive(
|
||||
Debug,
|
||||
Copy,
|
||||
Clone,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
EnumIter,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Default,
|
||||
Display,
|
||||
EnumString,
|
||||
AsRefStr,
|
||||
)]
|
||||
#[strum(serialize_all = "UPPERCASE")]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
pub enum InputFetchMethod {
|
||||
#[default]
|
||||
GET,
|
||||
@@ -174,39 +144,9 @@ pub enum InputFetchMethod {
|
||||
}
|
||||
|
||||
impl InputFetchMethod {
|
||||
const GET_METHOD: &'static str = "GET";
|
||||
const POST_METHOD: &'static str = "POST";
|
||||
|
||||
pub fn is_default(value: &InputFetchMethod) -> bool { matches!(value, Self::GET) }
|
||||
}
|
||||
|
||||
impl Display for InputFetchMethod {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
Self::GET => Self::GET_METHOD,
|
||||
Self::POST => Self::POST_METHOD,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for InputFetchMethod {
|
||||
type Err = TuliproxError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, TuliproxError> {
|
||||
if s.eq(Self::GET_METHOD) {
|
||||
Ok(Self::GET)
|
||||
} else if s.eq(Self::POST_METHOD) {
|
||||
Ok(Self::POST)
|
||||
} else {
|
||||
Err(TuliproxError::ConfigInput(format!("Unknown Fetch Method: {}", s)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -671,15 +611,22 @@ impl ConfigInputDto {
|
||||
}
|
||||
}
|
||||
InputType::Plex => {
|
||||
if !media_server.has_any_plex_token() {
|
||||
if trimmed_url.is_empty() {
|
||||
if !is_non_blank_optional_string(&media_server.account_token) {
|
||||
return Err(TuliproxError::ConfigInput(format!(
|
||||
"media-server input type plex without input.url requires media_server.account_token for MyPlex discovery (input: {})",
|
||||
self.name
|
||||
)));
|
||||
}
|
||||
if !media_server.has_plex_server_selector() {
|
||||
return Err(TuliproxError::ConfigInput(format!(
|
||||
"media-server input type plex requires a server selector such as media_server.server_id or media_server.server_name when input.url is omitted (input: {})",
|
||||
self.name
|
||||
)));
|
||||
}
|
||||
} else if !is_non_blank_optional_string(&media_server.token) {
|
||||
return Err(TuliproxError::ConfigInput(format!(
|
||||
"media-server input type plex requires media_server.account_token or media_server.token (input: {})",
|
||||
self.name
|
||||
)));
|
||||
}
|
||||
if trimmed_url.is_empty() && !media_server.has_plex_server_selector() {
|
||||
return Err(TuliproxError::ConfigInput(format!(
|
||||
"media-server input type plex requires a server selector such as media_server.machine_id, media_server.server_id, or media_server.server_name when input.url is omitted (input: {})",
|
||||
"media-server input type plex with input.url requires media_server.token for direct PMS access (input: {})",
|
||||
self.name
|
||||
)));
|
||||
}
|
||||
@@ -998,49 +945,56 @@ pub fn is_default_dns_prefer(v: &DnsPrefer) -> bool { *v == DnsPrefer::default()
|
||||
pub fn is_default_on_resolve_error(v: &OnResolveErrorPolicy) -> bool { *v == OnResolveErrorPolicy::default() }
|
||||
pub fn is_default_on_connect_error(v: &OnConnectErrorPolicy) -> bool { *v == OnConnectErrorPolicy::default() }
|
||||
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)]
|
||||
#[derive(
|
||||
Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default, EnumString, AsRefStr, Display,
|
||||
)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderUrlSelectionPolicy {
|
||||
#[serde(rename = "resume_last_working")]
|
||||
#[default]
|
||||
ResumeLastWorking,
|
||||
#[serde(rename = "restart_from_first")]
|
||||
RestartFromFirst,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)]
|
||||
#[derive(
|
||||
Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default, EnumString, AsRefStr, Display,
|
||||
)]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DnsPrefer {
|
||||
#[serde(rename = "ipv4")]
|
||||
Ipv4,
|
||||
#[serde(rename = "ipv6")]
|
||||
Ipv6,
|
||||
#[serde(rename = "system")]
|
||||
#[default]
|
||||
System,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq)]
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, EnumString, AsRefStr, Display)]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DnsScheme {
|
||||
#[serde(rename = "http")]
|
||||
Http,
|
||||
#[serde(rename = "https")]
|
||||
Https,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)]
|
||||
#[derive(
|
||||
Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default, EnumString, AsRefStr, Display,
|
||||
)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OnResolveErrorPolicy {
|
||||
#[serde(rename = "keep_last_good")]
|
||||
#[default]
|
||||
KeepLastGood,
|
||||
#[serde(rename = "fallback_to_hostname")]
|
||||
FallbackToHostname,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)]
|
||||
#[derive(
|
||||
Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default, EnumString, AsRefStr, Display,
|
||||
)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OnConnectErrorPolicy {
|
||||
#[serde(rename = "try_next_ip")]
|
||||
#[default]
|
||||
TryNextIp,
|
||||
#[serde(rename = "rotate_provider_url")]
|
||||
RotateProviderUrl,
|
||||
}
|
||||
|
||||
@@ -1301,7 +1255,7 @@ mod tests {
|
||||
let provider: ConfigProviderDto = serde_json::from_str(
|
||||
r#"{"name":"provider-a","urls":["http://primary.example.com"],"provider_url_selection_policy":"restart_from_first"}"#,
|
||||
)
|
||||
.expect("provider dto should deserialize");
|
||||
.expect("provider dto should deserialize");
|
||||
|
||||
assert_eq!(provider.provider_url_selection_policy, ProviderUrlSelectionPolicy::RestartFromFirst);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,31 @@
|
||||
use crate::{
|
||||
error::TuliproxError,
|
||||
utils::{
|
||||
default_as_true, is_blank_optional_str, is_blank_optional_string, is_default_runtime_config_report_format,
|
||||
is_false, is_true,
|
||||
},
|
||||
use crate::utils::{
|
||||
default_as_true, is_blank_optional_str, is_blank_optional_string, is_default_runtime_config_report_format,
|
||||
is_false, is_true,
|
||||
};
|
||||
use enum_iterator::Sequence;
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
use strum_macros::{AsRefStr, Display, EnumIter, EnumString};
|
||||
|
||||
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default, Sequence)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Default,
|
||||
EnumIter,
|
||||
EnumString,
|
||||
AsRefStr,
|
||||
Display,
|
||||
)]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RuntimeConfigReportFormat {
|
||||
#[default]
|
||||
Yaml,
|
||||
Json,
|
||||
}
|
||||
|
||||
impl RuntimeConfigReportFormat {
|
||||
const JSON: &'static str = "json";
|
||||
const YAML: &'static str = "yaml";
|
||||
}
|
||||
|
||||
impl Display for RuntimeConfigReportFormat {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let str = match self {
|
||||
RuntimeConfigReportFormat::Yaml => RuntimeConfigReportFormat::YAML,
|
||||
RuntimeConfigReportFormat::Json => RuntimeConfigReportFormat::JSON,
|
||||
};
|
||||
write!(f, "{str}")
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for RuntimeConfigReportFormat {
|
||||
type Err = TuliproxError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
RuntimeConfigReportFormat::YAML => Ok(RuntimeConfigReportFormat::Yaml),
|
||||
RuntimeConfigReportFormat::JSON => Ok(RuntimeConfigReportFormat::Json),
|
||||
_ => Err(TuliproxError::Config(format!("Invalid Runtime config report format {s}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LogConfigDto {
|
||||
|
||||
@@ -2,31 +2,30 @@ use crate::{
|
||||
error::TuliproxError,
|
||||
utils::{
|
||||
default_as_true, default_media_server_catalog_page_size, default_media_server_catalog_request_delay_ms,
|
||||
get_trimmed_string, is_blank_optional_string, is_default_media_server_catalog_page_size,
|
||||
is_default_media_server_catalog_request_delay_ms, is_false, is_non_blank_optional_string, is_true,
|
||||
deserialize_as_option_string, get_trimmed_string, is_blank_optional_string,
|
||||
is_default_media_server_catalog_page_size, is_default_media_server_catalog_request_delay_ms, is_false,
|
||||
is_non_blank_optional_string, is_true,
|
||||
},
|
||||
};
|
||||
use enum_iterator::Sequence;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)]
|
||||
pub enum MediaServerCatalogRefreshModeDto {
|
||||
#[serde(rename = "manual")]
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MediaServerCatalogRefreshMode {
|
||||
#[default]
|
||||
Manual,
|
||||
#[serde(rename = "scheduled")]
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
pub fn is_default_media_server_catalog_refresh_mode(value: &MediaServerCatalogRefreshModeDto) -> bool {
|
||||
*value == MediaServerCatalogRefreshModeDto::default()
|
||||
pub fn is_default_media_server_catalog_refresh_mode(value: &MediaServerCatalogRefreshMode) -> bool {
|
||||
*value == MediaServerCatalogRefreshMode::default()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MediaServerCatalogConfigDto {
|
||||
#[serde(default, skip_serializing_if = "is_default_media_server_catalog_refresh_mode")]
|
||||
pub refresh_mode: MediaServerCatalogRefreshModeDto,
|
||||
pub refresh_mode: MediaServerCatalogRefreshMode,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub refresh_on_startup: bool,
|
||||
#[serde(
|
||||
@@ -50,7 +49,7 @@ pub struct MediaServerCatalogConfigDto {
|
||||
impl Default for MediaServerCatalogConfigDto {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
refresh_mode: MediaServerCatalogRefreshModeDto::default(),
|
||||
refresh_mode: MediaServerCatalogRefreshMode::default(),
|
||||
refresh_on_startup: false,
|
||||
page_size: default_media_server_catalog_page_size(),
|
||||
request_delay_ms: default_media_server_catalog_request_delay_ms(),
|
||||
@@ -74,24 +73,23 @@ impl MediaServerCatalogConfigDto {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)]
|
||||
pub enum MediaServerPlaybackInfoPolicyDto {
|
||||
#[serde(rename = "on_demand")]
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MediaServerPlaybackInfoPolicy {
|
||||
#[default]
|
||||
OnDemand,
|
||||
#[serde(rename = "disabled")]
|
||||
Disabled,
|
||||
}
|
||||
|
||||
pub fn is_default_media_server_playback_info_policy(value: &MediaServerPlaybackInfoPolicyDto) -> bool {
|
||||
*value == MediaServerPlaybackInfoPolicyDto::default()
|
||||
pub fn is_default_media_server_playback_info_policy(value: &MediaServerPlaybackInfoPolicy) -> bool {
|
||||
*value == MediaServerPlaybackInfoPolicy::default()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MediaServerPlaybackConfigDto {
|
||||
#[serde(default, skip_serializing_if = "is_default_media_server_playback_info_policy")]
|
||||
pub playback_info_policy: MediaServerPlaybackInfoPolicyDto,
|
||||
pub playback_info_policy: MediaServerPlaybackInfoPolicy,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub preflight_streams: bool,
|
||||
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
|
||||
@@ -103,7 +101,7 @@ pub struct MediaServerPlaybackConfigDto {
|
||||
impl Default for MediaServerPlaybackConfigDto {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
playback_info_policy: MediaServerPlaybackInfoPolicyDto::default(),
|
||||
playback_info_policy: MediaServerPlaybackInfoPolicy::default(),
|
||||
preflight_streams: false,
|
||||
direct_play_only: default_as_true(),
|
||||
allow_transcode: false,
|
||||
@@ -115,38 +113,46 @@ impl MediaServerPlaybackConfigDto {
|
||||
pub fn is_default(&self) -> bool { self == &Self::default() }
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)]
|
||||
pub enum MediaServerImagePolicyDto {
|
||||
#[serde(rename = "proxy_on_demand")]
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MediaServerImagePolicy {
|
||||
#[default]
|
||||
ProxyOnDemand,
|
||||
#[serde(rename = "disabled")]
|
||||
Disabled,
|
||||
}
|
||||
|
||||
pub fn is_default_media_server_image_policy(value: &MediaServerImagePolicyDto) -> bool {
|
||||
*value == MediaServerImagePolicyDto::default()
|
||||
pub fn is_default_media_server_image_policy(value: &MediaServerImagePolicy) -> bool {
|
||||
*value == MediaServerImagePolicy::default()
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq)]
|
||||
pub enum MediaServerLibraryKindDto {
|
||||
#[serde(rename = "movies")]
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MediaServerLibraryKind {
|
||||
Movies,
|
||||
#[serde(rename = "tvshows", alias = "shows", alias = "series")]
|
||||
#[serde(alias = "shows", alias = "series")]
|
||||
TvShows,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MediaServerLibrarySelectorDetailsDto {
|
||||
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "deserialize_as_option_string",
|
||||
skip_serializing_if = "is_blank_optional_string"
|
||||
)]
|
||||
pub id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "deserialize_as_option_string",
|
||||
skip_serializing_if = "is_blank_optional_string"
|
||||
)]
|
||||
pub key: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<MediaServerLibraryKindDto>,
|
||||
pub kind: Option<MediaServerLibraryKind>,
|
||||
}
|
||||
|
||||
impl MediaServerLibrarySelectorDetailsDto {
|
||||
@@ -166,12 +172,12 @@ impl MediaServerLibrarySelectorDetailsDto {
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum MediaServerLibrarySelectorDto {
|
||||
pub enum MediaServerLibrarySelector {
|
||||
Name(String),
|
||||
Detailed(MediaServerLibrarySelectorDetailsDto),
|
||||
}
|
||||
|
||||
impl MediaServerLibrarySelectorDto {
|
||||
impl MediaServerLibrarySelector {
|
||||
fn prepare(&mut self) {
|
||||
match self {
|
||||
Self::Name(name) => *name = name.trim().to_string(),
|
||||
@@ -191,13 +197,13 @@ impl MediaServerLibrarySelectorDto {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MediaServerInputConfigDto {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub libraries: Vec<MediaServerLibrarySelectorDto>,
|
||||
pub libraries: Vec<MediaServerLibrarySelector>,
|
||||
#[serde(default, skip_serializing_if = "MediaServerCatalogConfigDto::is_default")]
|
||||
pub catalog: MediaServerCatalogConfigDto,
|
||||
#[serde(default, skip_serializing_if = "MediaServerPlaybackConfigDto::is_default")]
|
||||
pub playback: MediaServerPlaybackConfigDto,
|
||||
#[serde(default, skip_serializing_if = "is_default_media_server_image_policy")]
|
||||
pub image_policy: MediaServerImagePolicyDto,
|
||||
pub image_policy: MediaServerImagePolicy,
|
||||
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
|
||||
pub token: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
|
||||
@@ -209,8 +215,6 @@ pub struct MediaServerInputConfigDto {
|
||||
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
|
||||
pub server_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
|
||||
pub machine_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
|
||||
pub server_name: Option<String>,
|
||||
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
|
||||
pub prefer_https: bool,
|
||||
@@ -224,13 +228,12 @@ impl Default for MediaServerInputConfigDto {
|
||||
libraries: Vec::new(),
|
||||
catalog: MediaServerCatalogConfigDto::default(),
|
||||
playback: MediaServerPlaybackConfigDto::default(),
|
||||
image_policy: MediaServerImagePolicyDto::default(),
|
||||
image_policy: MediaServerImagePolicy::default(),
|
||||
token: None,
|
||||
api_key: None,
|
||||
user_id: None,
|
||||
account_token: None,
|
||||
server_id: None,
|
||||
machine_id: None,
|
||||
server_name: None,
|
||||
prefer_https: default_as_true(),
|
||||
allow_relay: false,
|
||||
@@ -245,7 +248,6 @@ impl MediaServerInputConfigDto {
|
||||
self.user_id = get_trimmed_string(self.user_id.as_deref());
|
||||
self.account_token = get_trimmed_string(self.account_token.as_deref());
|
||||
self.server_id = get_trimmed_string(self.server_id.as_deref());
|
||||
self.machine_id = get_trimmed_string(self.machine_id.as_deref());
|
||||
self.server_name = get_trimmed_string(self.server_name.as_deref());
|
||||
|
||||
for library in &mut self.libraries {
|
||||
@@ -257,7 +259,7 @@ impl MediaServerInputConfigDto {
|
||||
self.normalize();
|
||||
self.catalog.prepare(input_name)?;
|
||||
|
||||
if self.libraries.iter().any(MediaServerLibrarySelectorDto::is_empty) {
|
||||
if self.libraries.iter().any(MediaServerLibrarySelector::is_empty) {
|
||||
return Err(TuliproxError::ConfigInput(format!(
|
||||
"media_server library selectors must not be empty (input: {input_name})"
|
||||
)));
|
||||
@@ -274,9 +276,7 @@ impl MediaServerInputConfigDto {
|
||||
}
|
||||
|
||||
pub fn has_plex_server_selector(&self) -> bool {
|
||||
is_non_blank_optional_string(&self.server_id)
|
||||
|| is_non_blank_optional_string(&self.machine_id)
|
||||
|| is_non_blank_optional_string(&self.server_name)
|
||||
is_non_blank_optional_string(&self.server_id) || is_non_blank_optional_string(&self.server_name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ mod tests {
|
||||
|
||||
fn media_server_config_with_library() -> MediaServerInputConfigDto {
|
||||
MediaServerInputConfigDto {
|
||||
libraries: vec![MediaServerLibrarySelectorDto::Name("Movies".to_string())],
|
||||
libraries: vec![MediaServerLibrarySelector::Name("Movies".to_string())],
|
||||
..MediaServerInputConfigDto::default()
|
||||
}
|
||||
}
|
||||
@@ -338,7 +338,7 @@ mod tests {
|
||||
..ConfigInputDto::default()
|
||||
};
|
||||
let err = prepare_dto(&mut plex).expect_err("blank plex token should be rejected");
|
||||
assert!(err.to_string().contains("requires media_server.account_token or media_server.token"));
|
||||
assert!(err.to_string().contains("requires media_server.account_token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -371,10 +371,37 @@ mod tests {
|
||||
assert!(media_server.playback.direct_play_only);
|
||||
assert!(!media_server.playback.allow_transcode);
|
||||
assert!(!media_server.playback.preflight_streams);
|
||||
assert_eq!(media_server.image_policy, MediaServerImagePolicyDto::ProxyOnDemand);
|
||||
assert_eq!(media_server.image_policy, MediaServerImagePolicy::ProxyOnDemand);
|
||||
assert!(!media_server.allow_relay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_server_library_key_selector_accepts_numeric_yaml_scalars() {
|
||||
let media_server =
|
||||
serde_json::from_str::<MediaServerInputConfigDto>(r#"{"libraries":[{"key":10,"kind":"movies"}]}"#)
|
||||
.expect("numeric YAML-like key selectors should deserialize as strings");
|
||||
|
||||
assert_eq!(
|
||||
media_server.libraries,
|
||||
vec![MediaServerLibrarySelector::Detailed(MediaServerLibrarySelectorDetailsDto {
|
||||
key: Some("10".to_string()),
|
||||
kind: Some(MediaServerLibraryKind::Movies),
|
||||
..MediaServerLibrarySelectorDetailsDto::default()
|
||||
})]
|
||||
);
|
||||
|
||||
let by_id = serde_json::from_str::<MediaServerInputConfigDto>(r#"{"libraries":[{"id":42,"kind":"tvshows"}]}"#)
|
||||
.expect("numeric id selectors should deserialize as strings");
|
||||
assert_eq!(
|
||||
by_id.libraries,
|
||||
vec![MediaServerLibrarySelector::Detailed(MediaServerLibrarySelectorDetailsDto {
|
||||
id: Some("42".to_string()),
|
||||
kind: Some(MediaServerLibraryKind::TvShows),
|
||||
..MediaServerLibrarySelectorDetailsDto::default()
|
||||
})]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_server_enrichment_block_is_not_part_of_schema() {
|
||||
let err = serde_json::from_str::<MediaServerInputConfigDto>(
|
||||
@@ -443,7 +470,7 @@ mod tests {
|
||||
enabled: false,
|
||||
media_server: Some(MediaServerInputConfigDto {
|
||||
token: Some(" token-value ".to_string()),
|
||||
libraries: vec![MediaServerLibrarySelectorDto::Name(" ".to_string())],
|
||||
libraries: vec![MediaServerLibrarySelector::Name(" ".to_string())],
|
||||
catalog: MediaServerCatalogConfigDto { page_size: 0, ..MediaServerCatalogConfigDto::default() },
|
||||
..MediaServerInputConfigDto::default()
|
||||
}),
|
||||
@@ -513,13 +540,13 @@ mod tests {
|
||||
name: "plex_media_server".intern(),
|
||||
input_type: InputType::Plex,
|
||||
media_server: Some(MediaServerInputConfigDto {
|
||||
machine_id: Some("machine".to_string()),
|
||||
server_id: Some("server".to_string()),
|
||||
..media_server_config_with_library()
|
||||
}),
|
||||
..ConfigInputDto::default()
|
||||
};
|
||||
let err = prepare_dto(&mut without_token).expect_err("plex token should be mandatory");
|
||||
assert!(err.to_string().contains("requires media_server.account_token or media_server.token"));
|
||||
assert!(err.to_string().contains("requires media_server.account_token"));
|
||||
|
||||
let mut without_selector = ConfigInputDto {
|
||||
name: "plex_media_server".intern(),
|
||||
@@ -541,7 +568,7 @@ mod tests {
|
||||
input_type: InputType::Plex,
|
||||
media_server: Some(MediaServerInputConfigDto {
|
||||
account_token: Some("token".to_string()),
|
||||
machine_id: Some("machine".to_string()),
|
||||
server_id: Some("server".to_string()),
|
||||
..media_server_config_with_library()
|
||||
}),
|
||||
..ConfigInputDto::default()
|
||||
@@ -568,6 +595,40 @@ mod tests {
|
||||
assert_eq!(dto.input_type, InputType::Plex);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_rejects_plex_direct_url_without_server_token() {
|
||||
let mut dto = ConfigInputDto {
|
||||
name: "plex_media_server".intern(),
|
||||
input_type: InputType::Plex,
|
||||
url: "https://plex.example.invalid".to_string(),
|
||||
media_server: Some(MediaServerInputConfigDto {
|
||||
account_token: Some("account-token".to_string()),
|
||||
..media_server_config_with_library()
|
||||
}),
|
||||
..ConfigInputDto::default()
|
||||
};
|
||||
|
||||
let err = prepare_dto(&mut dto).expect_err("direct Plex URL requires the PMS server token");
|
||||
assert!(err.to_string().contains("requires media_server.token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_rejects_plex_discovery_without_account_token() {
|
||||
let mut dto = ConfigInputDto {
|
||||
name: "plex_media_server".intern(),
|
||||
input_type: InputType::Plex,
|
||||
media_server: Some(MediaServerInputConfigDto {
|
||||
token: Some("server-token".to_string()),
|
||||
server_id: Some("server".to_string()),
|
||||
..media_server_config_with_library()
|
||||
}),
|
||||
..ConfigInputDto::default()
|
||||
};
|
||||
|
||||
let err = prepare_dto(&mut dto).expect_err("Plex discovery requires the MyPlex account token");
|
||||
assert!(err.to_string().contains("requires media_server.account_token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_dns_defaults() {
|
||||
let dns = ProviderDnsDto::default();
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
use crate::error::TuliproxError;
|
||||
use enum_iterator::Sequence;
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
use strum_macros::{AsRefStr, Display, EnumIter, EnumString};
|
||||
|
||||
#[derive(
|
||||
Debug, Default, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Ord, PartialOrd,
|
||||
Debug,
|
||||
Default,
|
||||
Copy,
|
||||
Clone,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Ord,
|
||||
PartialOrd,
|
||||
EnumIter,
|
||||
EnumString,
|
||||
AsRefStr,
|
||||
Display,
|
||||
)]
|
||||
#[strum(serialize_all = "PascalCase")]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ProxyUserStatus {
|
||||
#[default]
|
||||
Active, // The account is in good standing and can stream content
|
||||
@@ -14,45 +27,3 @@ pub enum ProxyUserStatus {
|
||||
Disabled, // The account is inactive or deliberately disabled by the administrator.
|
||||
Pending,
|
||||
}
|
||||
|
||||
impl ProxyUserStatus {
|
||||
const ACTIVE: &'static str = "Active";
|
||||
const EXPIRED: &'static str = "Expired";
|
||||
const BANNED: &'static str = "Banned";
|
||||
const TRIAL: &'static str = "Trial";
|
||||
const DISABLED: &'static str = "Disabled";
|
||||
const PENDING: &'static str = "Pending";
|
||||
}
|
||||
|
||||
impl Display for ProxyUserStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
Self::Active => Self::ACTIVE,
|
||||
Self::Expired => Self::EXPIRED,
|
||||
Self::Banned => Self::BANNED,
|
||||
Self::Trial => Self::TRIAL,
|
||||
Self::Disabled => Self::DISABLED,
|
||||
Self::Pending => Self::PENDING,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ProxyUserStatus {
|
||||
type Err = TuliproxError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, TuliproxError> {
|
||||
match s {
|
||||
Self::ACTIVE => Ok(Self::Active),
|
||||
Self::EXPIRED => Ok(Self::Expired),
|
||||
Self::BANNED => Ok(Self::Banned),
|
||||
Self::TRIAL => Ok(Self::Trial),
|
||||
Self::DISABLED => Ok(Self::Disabled),
|
||||
Self::PENDING => Ok(Self::Pending),
|
||||
_ => Err(TuliproxError::Config(format!("Unknown ProxyUserStatus: {}", s))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +1,21 @@
|
||||
use crate::error::TuliproxError;
|
||||
use enum_iterator::Sequence;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
use strum_macros::{AsRefStr, EnumIter, EnumString};
|
||||
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, Sequence, Eq, PartialEq)]
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, Eq, PartialEq, EnumIter, EnumString, AsRefStr)]
|
||||
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ItemField {
|
||||
#[serde(rename = "group")]
|
||||
Group,
|
||||
#[serde(rename = "name")]
|
||||
Name,
|
||||
#[serde(rename = "title")]
|
||||
Title,
|
||||
#[serde(rename = "genre")]
|
||||
Genre,
|
||||
#[serde(rename = "url")]
|
||||
Url,
|
||||
#[serde(rename = "input")]
|
||||
Input,
|
||||
#[serde(rename = "type")]
|
||||
Type,
|
||||
#[serde(rename = "caption")]
|
||||
Caption,
|
||||
}
|
||||
|
||||
impl ItemField {
|
||||
const GROUP: &'static str = "Group";
|
||||
const NAME: &'static str = "Name";
|
||||
const TITLE: &'static str = "Title";
|
||||
const GENRE: &'static str = "Genre";
|
||||
const URL: &'static str = "Url";
|
||||
const INPUT: &'static str = "Input";
|
||||
const TYPE: &'static str = "Type";
|
||||
const CAPTION: &'static str = "Caption";
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match *self {
|
||||
Self::Group => Self::GROUP,
|
||||
Self::Name => Self::NAME,
|
||||
Self::Title => Self::TITLE,
|
||||
Self::Genre => Self::GENRE,
|
||||
Self::Url => Self::URL,
|
||||
Self::Input => Self::INPUT,
|
||||
Self::Type => Self::TYPE,
|
||||
Self::Caption => Self::CAPTION,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ItemField {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match *self {
|
||||
Self::Group => Self::GROUP,
|
||||
Self::Name => Self::NAME,
|
||||
Self::Title => Self::TITLE,
|
||||
Self::Genre => Self::GENRE,
|
||||
Self::Url => Self::URL,
|
||||
Self::Input => Self::INPUT,
|
||||
Self::Type => Self::TYPE,
|
||||
Self::Caption => Self::CAPTION,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ItemField {
|
||||
type Err = TuliproxError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, TuliproxError> {
|
||||
if s.eq_ignore_ascii_case(Self::GROUP) {
|
||||
Ok(Self::Group)
|
||||
} else if s.eq_ignore_ascii_case(Self::NAME) {
|
||||
Ok(Self::Name)
|
||||
} else if s.eq_ignore_ascii_case(Self::TITLE) {
|
||||
Ok(Self::Title)
|
||||
} else if s.eq_ignore_ascii_case(Self::GENRE) {
|
||||
Ok(Self::Genre)
|
||||
} else if s.eq_ignore_ascii_case(Self::CAPTION) {
|
||||
Ok(Self::Caption)
|
||||
} else if s.eq_ignore_ascii_case(Self::URL) {
|
||||
Ok(Self::Url)
|
||||
} else if s.eq_ignore_ascii_case(Self::INPUT) {
|
||||
Ok(Self::Input)
|
||||
} else if s.eq_ignore_ascii_case(Self::TYPE) {
|
||||
Ok(Self::Type)
|
||||
} else {
|
||||
Err(TuliproxError::Config(format!("Unknown ItemField: {}", s)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ItemField {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
@@ -101,3 +25,43 @@ impl<'de> Deserialize<'de> for ItemField {
|
||||
ItemField::from_str(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ItemField {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Group => "Group",
|
||||
Self::Name => "Name",
|
||||
Self::Title => "Title",
|
||||
Self::Genre => "Genre",
|
||||
Self::Url => "Url",
|
||||
Self::Input => "Input",
|
||||
Self::Type => "Type",
|
||||
Self::Caption => "Caption",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn item_field_from_str_keeps_legacy_case_insensitive_parsing() {
|
||||
assert_eq!(ItemField::from_str("Group").expect("legacy field casing parses"), ItemField::Group);
|
||||
assert_eq!(ItemField::from_str("TITLE").expect("upper case field parses"), ItemField::Title);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_field_deserialize_uses_case_insensitive_strum_parser() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ItemField>("\"Group\"").expect("legacy field casing parses"),
|
||||
ItemField::Group
|
||||
);
|
||||
assert_eq!(serde_json::from_str::<ItemField>("\"TITLE\"").expect("upper case field parses"), ItemField::Title);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_field_serialize_keeps_lowercase_config_shape() {
|
||||
assert_eq!(serde_json::to_string(&ItemField::Group).expect("item field serializes"), "\"group\"");
|
||||
}
|
||||
}
|
||||
|
||||
+19
-46
@@ -3,13 +3,10 @@ use crate::{
|
||||
foundation::{apply_templates_to_pattern_single, get_filter, prepare_templates, Filter, MapperScript},
|
||||
model::PatternTemplate,
|
||||
};
|
||||
use enum_iterator::Sequence;
|
||||
use log::trace;
|
||||
use std::{
|
||||
fmt::Display,
|
||||
str::FromStr,
|
||||
sync::{atomic::AtomicU32, Arc},
|
||||
};
|
||||
use std::sync::{atomic::AtomicU32, Arc};
|
||||
use strum_macros::{AsRefStr, Display, EnumIter, EnumString};
|
||||
|
||||
pub const COUNTER_FIELDS: &[&str] = &["name", "title", "caption", "chno"];
|
||||
|
||||
pub const MAPPER_FIELDS: &[&str] = &[
|
||||
@@ -38,53 +35,29 @@ macro_rules! valid_property {
|
||||
}
|
||||
pub use valid_property;
|
||||
|
||||
#[derive(Debug, Default, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq)]
|
||||
#[derive(
|
||||
Debug,
|
||||
Default,
|
||||
Copy,
|
||||
Clone,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
EnumIter,
|
||||
EnumString,
|
||||
AsRefStr,
|
||||
Display,
|
||||
)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CounterModifier {
|
||||
#[default]
|
||||
#[serde(rename = "assign")]
|
||||
Assign,
|
||||
#[serde(rename = "suffix")]
|
||||
Suffix,
|
||||
#[serde(rename = "prefix")]
|
||||
Prefix,
|
||||
}
|
||||
|
||||
impl CounterModifier {
|
||||
const ASSIGN: &'static str = "assign";
|
||||
const SUFFIX: &'static str = "suffix";
|
||||
const PREFIX: &'static str = "prefix";
|
||||
}
|
||||
|
||||
impl Display for CounterModifier {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
Self::Assign => Self::ASSIGN,
|
||||
Self::Suffix => Self::SUFFIX,
|
||||
Self::Prefix => Self::PREFIX,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for CounterModifier {
|
||||
type Err = TuliproxError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, TuliproxError> {
|
||||
if s.eq("assign") {
|
||||
Ok(Self::Assign)
|
||||
} else if s.eq("suffix") {
|
||||
Ok(Self::Suffix)
|
||||
} else if s.eq("prefix") {
|
||||
Ok(Self::Prefix)
|
||||
} else {
|
||||
Err(TuliproxError::Config(format!("Unknown CounterModifier: {}", s)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
|
||||
pub struct MappingCounterDefinition {
|
||||
pub filter: String,
|
||||
|
||||
@@ -10,36 +10,36 @@ use crate::{
|
||||
get_provider_id, obfuscate_text, Internable,
|
||||
},
|
||||
};
|
||||
use enum_iterator::Sequence;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fmt::{Display, Formatter, Write},
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
};
|
||||
use strum_macros::{AsRefStr, Display, EnumIter, EnumString};
|
||||
// https://de.wikipedia.org/wiki/M3U
|
||||
// https://siptv.eu/howto/playlist.html
|
||||
|
||||
pub type VirtualId = u32;
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, Default, Display, EnumString, AsRefStr)]
|
||||
#[repr(u8)]
|
||||
pub enum XtreamCluster {
|
||||
#[default]
|
||||
#[strum(serialize = "Live", serialize = "live")]
|
||||
Live = 1,
|
||||
|
||||
#[strum(serialize = "Video", serialize = "video", serialize = "vod", serialize = "movie")]
|
||||
Video = 2,
|
||||
|
||||
#[strum(serialize = "Series", serialize = "series")]
|
||||
Series = 3,
|
||||
}
|
||||
|
||||
impl XtreamCluster {
|
||||
pub const fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Live => "Live",
|
||||
Self::Video => "Video",
|
||||
Self::Series => "Series",
|
||||
}
|
||||
}
|
||||
pub const fn as_stream_type(&self) -> &str {
|
||||
pub fn as_str(&self) -> &str { self.as_ref() }
|
||||
|
||||
pub fn as_stream_type(&self) -> &str {
|
||||
match self {
|
||||
Self::Live => "live",
|
||||
Self::Video => "movie",
|
||||
@@ -48,23 +48,6 @@ impl XtreamCluster {
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for XtreamCluster {
|
||||
type Err = TuliproxError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"live" => Ok(XtreamCluster::Live),
|
||||
"video" | "vod" | "movie" => Ok(XtreamCluster::Video),
|
||||
"series" => Ok(XtreamCluster::Series),
|
||||
_ => Err(TuliproxError::Config(format!("Invalid XtreamCluster: {s}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for XtreamCluster {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.as_str()) }
|
||||
}
|
||||
|
||||
impl TryFrom<PlaylistItemType> for XtreamCluster {
|
||||
type Error = String;
|
||||
fn try_from(item_type: PlaylistItemType) -> Result<Self, Self::Error> {
|
||||
@@ -82,7 +65,7 @@ impl TryFrom<PlaylistItemType> for XtreamCluster {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, Default, Sequence)]
|
||||
#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, Default, EnumIter)]
|
||||
#[repr(u8)]
|
||||
pub enum PlaylistItemType {
|
||||
#[default]
|
||||
@@ -688,7 +671,7 @@ impl XtreamMappingOptions {
|
||||
|
||||
fn is_trusted_web_ui_resource_path(resource_url: &str) -> bool {
|
||||
const TRUSTED_WEB_UI_RESOURCE_PREFIXES: [&str; 1] = ["/api/v1/library/thumbnail/"];
|
||||
TRUSTED_WEB_UI_RESOURCE_PREFIXES.iter().any(|prefix| resource_url.contains(prefix))
|
||||
TRUSTED_WEB_UI_RESOURCE_PREFIXES.iter().any(|prefix| resource_url.starts_with(prefix))
|
||||
}
|
||||
|
||||
fn build_reverse_proxy_base_url(
|
||||
@@ -1047,6 +1030,7 @@ impl PlaylistItem {
|
||||
added: None,
|
||||
release_date: None,
|
||||
series_release_date: None,
|
||||
plot: None,
|
||||
tmdb: None,
|
||||
movie_image: "".intern(),
|
||||
container_extension: container_extension.intern(),
|
||||
@@ -1462,6 +1446,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_resource_url_does_not_trust_absolute_urls_containing_internal_thumbnail_path() {
|
||||
let options = sample_options();
|
||||
let resource_url = "https://provider.example/api/v1/library/thumbnail/abc";
|
||||
|
||||
assert_eq!(
|
||||
options.get_resource_url(XtreamCluster::Series, PlaylistItemType::Series, 1, resource_url, "logo",),
|
||||
concat_path(&options.base_url, &obfuscate_text(&options.encrypt_secret, resource_url)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xtream_playlist_item_conversion_falls_back_to_numeric_id_in_url() {
|
||||
let item = PlaylistItem {
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
use crate::error::TuliproxError;
|
||||
use enum_iterator::Sequence;
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)]
|
||||
use strum_macros::AsRefStr;
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default, AsRefStr)]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProcessingOrder {
|
||||
#[serde(rename = "frm")]
|
||||
#[default]
|
||||
Frm,
|
||||
#[serde(rename = "fmr")]
|
||||
Fmr,
|
||||
#[serde(rename = "rfm")]
|
||||
Rfm,
|
||||
#[serde(rename = "rmf")]
|
||||
Rmf,
|
||||
#[serde(rename = "mfr")]
|
||||
Mfr,
|
||||
#[serde(rename = "mrf")]
|
||||
Mrf,
|
||||
}
|
||||
|
||||
|
||||
@@ -317,6 +317,8 @@ pub struct EpisodeStreamProperties {
|
||||
pub release_date: Option<Arc<str>>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_arc_str")]
|
||||
pub series_release_date: Option<Arc<str>>, // Global series release date
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_arc_str")]
|
||||
pub plot: Option<Arc<str>>,
|
||||
#[serde(default, deserialize_with = "deserialize_number_from_string")]
|
||||
pub tmdb: Option<u32>,
|
||||
#[serde(default, deserialize_with = "arc_str_none_default_on_null")]
|
||||
@@ -915,6 +917,7 @@ impl EpisodeStreamProperties {
|
||||
release_date: Some(episode.release_date.clone()),
|
||||
// Inherit global release date from the Series object if available
|
||||
series_release_date: series.release_date.clone(),
|
||||
plot: episode.plot.clone(),
|
||||
tmdb: episode.tmdb.or(series.tmdb),
|
||||
movie_image: episode.movie_image.clone(),
|
||||
container_extension: episode.container_extension.clone(),
|
||||
|
||||
@@ -1,47 +1,24 @@
|
||||
use crate::error::TuliproxError;
|
||||
use enum_iterator::Sequence;
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
use strum_macros::{Display, EnumIter, EnumString};
|
||||
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Hash, Default)]
|
||||
#[derive(
|
||||
Debug,
|
||||
Copy,
|
||||
Clone,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Default,
|
||||
EnumIter,
|
||||
Display,
|
||||
EnumString,
|
||||
)]
|
||||
#[strum(serialize_all = "PascalCase")]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StrmExportStyle {
|
||||
#[serde(rename = "kodi")]
|
||||
#[default]
|
||||
Kodi,
|
||||
#[serde(rename = "emby")]
|
||||
Emby,
|
||||
#[serde(rename = "jellyfin")]
|
||||
Jellyfin,
|
||||
}
|
||||
|
||||
impl StrmExportStyle {
|
||||
const KODI: &'static str = "Kodi";
|
||||
const EMBY: &'static str = "Emby";
|
||||
const JELLYFIN: &'static str = "Jellyfin";
|
||||
}
|
||||
|
||||
impl Display for StrmExportStyle {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match *self {
|
||||
Self::Kodi => Self::KODI,
|
||||
Self::Emby => Self::EMBY,
|
||||
Self::Jellyfin => Self::JELLYFIN,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for StrmExportStyle {
|
||||
type Err = TuliproxError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
Self::KODI => Ok(Self::Kodi),
|
||||
Self::EMBY => Ok(Self::Emby),
|
||||
Self::JELLYFIN => Ok(Self::Jellyfin),
|
||||
_ => Err(TuliproxError::Config(format!("Unknown StrmExportStyle: {s}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,13 @@
|
||||
use crate::error::TuliproxError;
|
||||
use enum_iterator::Sequence;
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
use strum_macros::{Display, EnumIter, EnumString};
|
||||
|
||||
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Hash)]
|
||||
#[derive(
|
||||
Debug, Copy, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, EnumIter, Display, EnumString,
|
||||
)]
|
||||
#[strum(serialize_all = "PascalCase", ascii_case_insensitive)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TargetType {
|
||||
#[serde(rename = "m3u")]
|
||||
M3u,
|
||||
#[serde(rename = "xtream")]
|
||||
Xtream,
|
||||
#[serde(rename = "strm")]
|
||||
Strm,
|
||||
#[serde(rename = "hdhomerun")]
|
||||
HdHomeRun,
|
||||
}
|
||||
|
||||
impl TargetType {
|
||||
const M3U: &'static str = "M3u";
|
||||
const XTREAM: &'static str = "Xtream";
|
||||
const STRM: &'static str = "Strm";
|
||||
const HDHOMERUN: &'static str = "HdHomeRun";
|
||||
}
|
||||
|
||||
impl Display for TargetType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match *self {
|
||||
Self::M3u => Self::M3U,
|
||||
Self::Xtream => Self::XTREAM,
|
||||
Self::Strm => Self::STRM,
|
||||
Self::HdHomeRun => Self::HDHOMERUN,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for TargetType {
|
||||
type Err = TuliproxError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
Self::M3U => Ok(Self::M3u),
|
||||
Self::XTREAM => Ok(Self::Xtream),
|
||||
Self::STRM => Ok(Self::Strm),
|
||||
Self::HDHOMERUN => Ok(Self::HdHomeRun),
|
||||
_ => Err(TuliproxError::Config(format!("Unknown TargetType: {s}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{error::TuliproxError, utils::Internable};
|
||||
use enum_iterator::Sequence;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::{fmt, str::FromStr, sync::Arc};
|
||||
use strum_macros::EnumIter;
|
||||
|
||||
const DASHBOARD: &str = "dashboard";
|
||||
const STATS: &str = "stats";
|
||||
@@ -17,7 +17,7 @@ const RBAC: &str = "rbac";
|
||||
const SOURCE_EDITOR: &str = "source_editor";
|
||||
const STREAM_HISTORY: &str = "stream_history";
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, Sequence)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, EnumIter)]
|
||||
pub enum ViewType {
|
||||
#[default]
|
||||
Dashboard,
|
||||
|
||||
Reference in New Issue
Block a user