From 945308a65bc7cd2e489e2bc12759099e51c6d7ee Mon Sep 17 00:00:00 2001 From: Juanjo Presa Date: Mon, 11 May 2026 13:38:06 +0200 Subject: [PATCH] feat: add Plex media-server catalog and playback baseline (#748) * feat: add Plex media-server catalog baseline * feat: add Plex media-server playback baseline --- Cargo.lock | 116 +- Cargo.toml | 17 +- backend/Cargo.toml | 4 +- backend/src/api/api_utils.rs | 261 +++- backend/src/api/endpoints/xtream_api.rs | 28 +- backend/src/api/setup_api.rs | 2 +- backend/src/media_server/catalog.rs | 47 +- backend/src/media_server/client.rs | 9 +- backend/src/media_server/playback.rs | 127 +- backend/src/media_server/playlist_mapper.rs | 150 +- backend/src/media_server/plex/client.rs | 1237 +++++++++++++++++ backend/src/media_server/plex/dto.rs | 131 +- backend/src/media_server/plex/mapper.rs | 589 ++++++++ backend/src/media_server/plex/mod.rs | 2 + backend/src/media_server/test_fixtures.rs | 43 +- backend/src/media_server/types.rs | 9 +- backend/src/model/config/api_proxy.rs | 5 +- backend/src/model/config/input.rs | 89 +- backend/src/model/xtream.rs | 4 +- backend/src/processing/processor/library.rs | 1 + backend/src/processing/processor/playlist.rs | 48 +- backend/src/processing/processor/sort.rs | 2 +- .../src/processing/processor/stream_probe.rs | 1 + backend/src/repository/library_repository.rs | 1 + backend/src/repository/playlist_repository.rs | 55 +- backend/src/runtime_config_report.rs | 5 +- backend/src/utils/file/config_reader.rs | 5 +- frontend/Cargo.toml | 3 +- .../app/components/config/log_config_view.rs | 7 +- .../config/reverse_proxy_config_view.rs | 10 +- .../components/config/webui_config_view.rs | 3 +- .../components/playlist/target/target_sort.rs | 2 +- .../userlist/proxy_user_credentials_form.rs | 3 +- shared/Cargo.toml | 9 +- shared/src/foundation/filter.rs | 8 +- shared/src/model/config/geoip.rs | 40 +- shared/src/model/config/input.rs | 198 +-- shared/src/model/config/log.rs | 57 +- .../src/model/config/media_server_catalog.rs | 165 ++- shared/src/model/config/proxy_user_status.rs | 63 +- shared/src/model/item_field.rs | 124 +- shared/src/model/mapping.rs | 65 +- shared/src/model/playlist.rs | 53 +- shared/src/model/processing_order.rs | 13 +- shared/src/model/stream_properties.rs | 3 + shared/src/model/strm_export_style.rs | 57 +- shared/src/model/target_type.rs | 50 +- shared/src/model/view_type.rs | 4 +- 48 files changed, 3196 insertions(+), 729 deletions(-) create mode 100644 backend/src/media_server/plex/client.rs create mode 100644 backend/src/media_server/plex/mapper.rs diff --git a/Cargo.lock b/Cargo.lock index 5fb9a4f3b..4c4f19e06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index dccf0f3ac..5b7b0ad66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1cf349a7b..3d1718858 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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" diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 88a739c5e..0b74295e4 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -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://"); + } 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, + 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::>(); + 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, + resource_url: &str, +) -> Result, 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 { + 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://" + ); + } + #[test] fn test_streaming_response_extension_disables_compression() { let mut response = Response::new(()); diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index 4ccfb8e9d..8bae6b406 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -169,6 +169,10 @@ pub(in crate::api) fn get_xtream_player_api_stream_url( action_path: &str, fallback_url: &Arc, ) -> Option> { + 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> { #[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::::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); diff --git a/backend/src/api/setup_api.rs b/backend/src/api/setup_api.rs index 75b8fe164..442c943f3 100644 --- a/backend/src/api/setup_api.rs +++ b/backend/src/api/setup_api.rs @@ -500,7 +500,7 @@ async fn persist_yaml_file( 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) diff --git a/backend/src/media_server/catalog.rs b/backend/src/media_server/catalog.rs index e4750254b..ec26d645a 100644 --- a/backend/src/media_server/catalog.rs +++ b/backend/src/media_server/catalog.rs @@ -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(library: &MediaServerLibrary, page: &MediaServerPage) -> 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, MediaServerError>>>, + movie_call_times: Mutex>, series_pages: Mutex, MediaServerError>>>, season_pages: Mutex, MediaServerError>>>, episode_pages: Mutex, MediaServerError>>>, @@ -216,6 +233,7 @@ mod tests { _library: &MediaServerLibraryRef, _page: MediaServerPageRequest, ) -> Result, 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()]); diff --git a/backend/src/media_server/client.rs b/backend/src/media_server/client.rs index 57d67b78f..a3d334e86 100644 --- a/backend/src/media_server/client.rs +++ b/backend/src/media_server/client.rs @@ -110,9 +110,14 @@ impl MediaServerHttpRequestBuilder { } pub async fn send(self) -> Result { + 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) -> Result { + 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) }) } } diff --git a/backend/src/media_server/playback.rs b/backend/src/media_server/playback.rs index 3b120899d..0b59d2e81 100644 --- a/backend/src/media_server/playback.rs +++ b/backend/src/media_server/playback.rs @@ -151,6 +151,59 @@ pub fn parse_media_server_stream_ref(input_name: &Arc, item_url: &str) -> R } } +pub fn parse_media_server_image_ref(resource_url: &str) -> Result { + 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 = 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::::from(parts[1].as_str()), + server_id: Arc::::from(parts[2].as_str()), + item_id: Arc::::from(parts[3].as_str()), + image_kind: query_value(query, "image_kind") + .map(Arc::::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::::from), + }), + "jellyfin" => Ok(MediaServerImageRef::Jellyfin { + input_name: Arc::::from(parts[1].as_str()), + server_id: Arc::::from(parts[2].as_str()), + item_id: Arc::::from(parts[3].as_str()), + image_kind: query_value(query, "image_kind") + .map(Arc::::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::::from), + }), + "plex" => Ok(MediaServerImageRef::Plex { + input_name: Arc::::from(parts[1].as_str()), + server_id: Arc::::from(parts[2].as_str()), + rating_key: Arc::::from(parts[3].as_str()), + image_path: query_value(query, "image_path") + .map(Arc::::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 { 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 { 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 { diff --git a/backend/src/media_server/playlist_mapper.rs b/backend/src/media_server/playlist_mapper.rs index e297b587b..56d55f002 100644 --- a/backend/src/media_server/playlist_mapper.rs +++ b/backend/src/media_server/playlist_mapper.rs @@ -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, Arc>) -> Arc { - 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::::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::::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::::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>) -> Option { +fn movie_details( + movie: &MediaServerMovie, + release_date: Option>, + image_url: Option>, +) -> Option { 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>) -> 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 { + 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::>(); - 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 { } fn release_date_from_year(year: Option) -> Option> { - year.filter(|year| *year > 0).map(|year| Arc::::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::::from(format!("{year}-07-01"))) } fn media_server_container_extension(technical: Option<&MediaServerTechnicalFacts>) -> Arc { @@ -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] diff --git a/backend/src/media_server/plex/client.rs b/backend/src/media_server/plex/client.rs new file mode 100644 index 000000000..2994830d1 --- /dev/null +++ b/backend/src/media_server/plex/client.rs @@ -0,0 +1,1237 @@ +use crate::media_server::plex::dto::{PlexMediaContainerDto, PlexResourcesDto, PlexSectionDto, PlexSectionsDto}; +use crate::media_server::plex::mapper::{ + plex_directory_to_season, plex_directory_to_series, plex_section_matches_selector, plex_section_to_library, + plex_video_to_episode, plex_video_to_movie, +}; +use crate::media_server::{ + MediaServerCatalogClient, MediaServerEpisode, MediaServerError, MediaServerErrorKind, MediaServerHttpClient, + MediaServerImageRef, MediaServerKind, MediaServerLibrary, MediaServerLibraryRef, MediaServerMovie, MediaServerPage, + MediaServerPageRequest, MediaServerResourceResponse, MediaServerSeason, MediaServerSeries, MediaServerStatus, + MediaServerStreamRef, MediaServerStreamResponse, +}; +use crate::model::{ConfigInput, MediaServerInputConfig}; +use futures::{StreamExt, TryStreamExt}; +use reqwest::header::{HeaderName, HeaderValue, RANGE}; +use reqwest::{Method, StatusCode}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use shared::model::{InputType, MediaServerLibrarySelector}; +use std::{ + collections::HashMap, + fmt::Write as _, + sync::{Arc, LazyLock}, +}; +use tokio::sync::Mutex; +use url::Url; + +const PLEX_TV_RESOURCES_URL: &str = "https://plex.tv/api/resources"; +const X_PLEX_TOKEN: HeaderName = HeaderName::from_static("x-plex-token"); +static PLEX_CONNECTION_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +#[derive(Debug, Clone)] +struct PlexClientConfig { + input_name: Arc, + direct_url: Option>, + token: Option>, + account_token: Option>, + server_id: Option>, + server_name: Option>, + prefer_https: bool, + allow_relay: bool, + libraries: Vec, +} + +#[derive(Debug, Clone)] +struct PlexConnectionState { + base_url: Arc, + token: Arc, + status: MediaServerStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct PlexConnectionCacheKey { + input_name: Arc, + direct_url: Option>, + token: Option>, + account_token: Option>, + server_id: Option>, + server_name: Option>, + prefer_https: bool, + allow_relay: bool, +} + +pub struct PlexCatalogClient { + http: MediaServerHttpClient, + config: PlexClientConfig, + connection: Mutex>, +} + +impl PlexCatalogClient { + pub fn from_input(input: &ConfigInput, http: MediaServerHttpClient) -> Result { + if input.input_type != InputType::Plex { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerDiscoveryFailed) + .provider("plex") + .detail("plex catalog client requires a plex input")); + } + let media_server = input.media_server.as_ref().ok_or_else(|| { + MediaServerError::new(MediaServerErrorKind::MediaServerDiscoveryFailed) + .provider("plex") + .detail("plex input is missing media_server configuration") + })?; + Ok(Self::new(input.name.clone(), input.url.as_str(), media_server, http)) + } + + pub fn new( + input_name: Arc, + input_url: &str, + media_server: &MediaServerInputConfig, + http: MediaServerHttpClient, + ) -> Self { + Self { + http, + config: PlexClientConfig { + input_name, + direct_url: non_blank(input_url).map(Arc::::from), + token: media_server.token.as_deref().and_then(non_blank).map(Arc::::from), + account_token: media_server.account_token.as_deref().and_then(non_blank).map(Arc::::from), + server_id: media_server.server_id.as_deref().and_then(non_blank).map(Arc::::from), + server_name: media_server.server_name.as_deref().and_then(non_blank).map(Arc::::from), + prefer_https: media_server.prefer_https, + allow_relay: media_server.allow_relay, + libraries: media_server.libraries.clone(), + }, + connection: Mutex::new(None), + } + } + + async fn connection(&self) -> Result { + if let Some(connection) = self.connection.lock().await.clone() { + return Ok(connection); + } + + let cache_key = self.connection_cache_key(); + if let Some(connection) = PLEX_CONNECTION_CACHE.lock().await.get(&cache_key).cloned() { + *self.connection.lock().await = Some(connection.clone()); + return Ok(connection); + } + + let connection = if self.config.direct_url.is_some() { + self.discover_direct().await? + } else { + self.discover_via_myplex().await? + }; + PLEX_CONNECTION_CACHE.lock().await.insert(cache_key, connection.clone()); + *self.connection.lock().await = Some(connection.clone()); + Ok(connection) + } + + fn connection_cache_key(&self) -> PlexConnectionCacheKey { + PlexConnectionCacheKey { + input_name: Arc::clone(&self.config.input_name), + direct_url: self.config.direct_url.clone(), + token: self.config.token.clone(), + account_token: self.config.account_token.clone(), + server_id: self.config.server_id.clone(), + server_name: self.config.server_name.clone(), + prefer_https: self.config.prefer_https, + allow_relay: self.config.allow_relay, + } + } + + async fn discover_direct(&self) -> Result { + let base_url = self.config.direct_url.clone().ok_or_else(|| { + MediaServerError::new(MediaServerErrorKind::MediaServerDiscoveryFailed) + .provider("plex") + .detail("direct Plex discovery requires a PMS URL") + })?; + let token = self.config.token.clone().ok_or_else(|| { + MediaServerError::new(MediaServerErrorKind::MediaServerAuthDenied) + .provider("plex") + .detail("direct Plex discovery requires media_server.token") + })?; + let identity = self.fetch_identity(&base_url, &token).await?; + verify_direct_selectors(&self.config, &identity)?; + let status = identity.into_status(None, None)?; + Ok(PlexConnectionState { base_url, token, status }) + } + + async fn discover_via_myplex(&self) -> Result { + let account_token = self.config.account_token.clone().ok_or_else(|| { + MediaServerError::new(MediaServerErrorKind::MediaServerAuthDenied) + .provider("plex") + .detail("Plex resource discovery requires media_server.account_token") + })?; + let resources: PlexResourcesDto = self.get_xml(PLEX_TV_RESOURCES_URL, &account_token, PlexOperation::Discovery).await?; + let resource = select_resource(&resources.devices, &self.config)?; + let resource_token = resource.access_token.as_deref().and_then(non_blank).map(Arc::::from).ok_or_else(|| { + MediaServerError::new(MediaServerErrorKind::MediaServerAuthDenied) + .provider("plex") + .detail("selected Plex resource did not expose a PMS access token") + })?; + let candidates = selected_connection_urls(resource, self.config.prefer_https, self.config.allow_relay)?; + + let mut last_error = None; + for candidate in candidates { + match self.fetch_identity(&candidate, &resource_token).await { + Ok(identity) => { + verify_resource_identity(resource, &identity)?; + let status = identity.into_status(resource.client_identifier.as_deref(), resource.owned.map(|owned| owned != 0))?; + return Ok(PlexConnectionState { base_url: candidate, token: resource_token, status }); + } + Err(error) => last_error = Some(error), + } + } + + Err(last_error.unwrap_or_else(|| { + MediaServerError::new(MediaServerErrorKind::MediaServerUnavailable) + .provider("plex") + .detail("no selected Plex connection could be verified") + })) + } + + async fn fetch_identity(&self, base_url: &Arc, token: &Arc) -> Result { + let url = pms_url(base_url, "/identity")?; + self.get_xml(&url, token, PlexOperation::Discovery).await + } + + async fn fetch_sections(&self, connection: &PlexConnectionState) -> Result { + let url = pms_url(&connection.base_url, "/library/sections")?; + self.get_xml(&url, &connection.token, PlexOperation::Catalog).await + } + + async fn fetch_catalog_page( + &self, + connection: &PlexConnectionState, + library: &MediaServerLibraryRef, + page: MediaServerPageRequest, + metadata_type: Option, + include_guids: bool, + ) -> Result { + let section = encode_url_path_segment(&library.library_id); + let mut url = pms_url(&connection.base_url, &format!("/library/sections/{section}/all"))?; + append_query_pair(&mut url, "X-Plex-Container-Start", &page.start.to_string()); + append_query_pair(&mut url, "X-Plex-Container-Size", &page.limit.to_string()); + if let Some(metadata_type) = metadata_type { + append_query_pair(&mut url, "type", &metadata_type.to_string()); + } + if include_guids { + append_query_pair(&mut url, "includeGuids", "1"); + } + self.get_xml(&url, &connection.token, PlexOperation::Catalog).await + } + + async fn get_xml(&self, url: &str, token: &Arc, operation: PlexOperation) -> Result + where + T: DeserializeOwned, + { + let response = self.get_response(url, token, operation).await?; + let body = response.text().await.map_err(|err| { + MediaServerError::from_reqwest_error_with_fallback( + &err, + operation.not_found_kind(), + operation.fallback_kind(), + ) + .provider("plex") + .detail("Plex response body read failed") + })?; + quick_xml::de::from_str(&body).map_err(|err| { + MediaServerError::new(operation.fallback_kind()) + .provider("plex") + .detail(format!("Plex XML decode failed: {err}")) + }) + } + + async fn get_response( + &self, + url: &str, + token: &Arc, + operation: PlexOperation, + ) -> Result { + let token = HeaderValue::from_str(token).map_err(|_| { + MediaServerError::new(MediaServerErrorKind::MediaServerAuthDenied) + .provider("plex") + .detail("Plex token contains invalid header characters") + })?; + let request = match operation { + PlexOperation::Discovery => self.http.request(Method::GET, url).discovery_errors(), + PlexOperation::Catalog => self.http.request(Method::GET, url).catalog_errors(), + PlexOperation::Playback => self.http.request(Method::GET, url).playback_errors(), + }; + let response = request.header(X_PLEX_TOKEN, token).send_with_error_detail("Plex request failed").await?; + let status = response.status(); + if !status.is_success() { + return Err(operation.status_error(status).detail(format!("Plex request returned {status}"))); + } + Ok(response) + } +} + +#[allow(async_fn_in_trait)] +impl MediaServerCatalogClient for PlexCatalogClient { + async fn discover(&self) -> Result { + Ok(self.connection().await?.status) + } + + async fn list_libraries(&self) -> Result, MediaServerError> { + let connection = self.connection().await?; + let sections = self.fetch_sections(&connection).await?; + select_libraries(&self.config, &connection.status.server_id, §ions.directories) + } + + async fn list_movies( + &self, + library: &MediaServerLibraryRef, + page: MediaServerPageRequest, + ) -> Result, MediaServerError> { + let connection = self.connection().await?; + let container = self.fetch_catalog_page(&connection, library, page, Some(1), true).await?; + let upstream_item_count = container.upstream_item_count(); + let items = container + .videos + .iter() + .filter_map(|video| plex_video_to_movie(&self.config.input_name, &connection.status.server_id, &library.library_id, video)) + .collect(); + Ok(MediaServerPage::with_upstream_item_count(page, container.total_size, upstream_item_count, items)) + } + + async fn list_series( + &self, + library: &MediaServerLibraryRef, + page: MediaServerPageRequest, + ) -> Result, MediaServerError> { + let connection = self.connection().await?; + let container = self.fetch_catalog_page(&connection, library, page, None, true).await?; + let upstream_item_count = container.upstream_item_count(); + let items = container + .directories + .iter() + .filter(|directory| directory.item_type.as_deref().is_none_or(|item_type| item_type.eq_ignore_ascii_case("show"))) + .filter_map(|directory| { + plex_directory_to_series(&self.config.input_name, &connection.status.server_id, &library.library_id, directory) + }) + .collect(); + Ok(MediaServerPage::with_upstream_item_count(page, container.total_size, upstream_item_count, items)) + } + + async fn list_seasons( + &self, + library: &MediaServerLibraryRef, + page: MediaServerPageRequest, + ) -> Result, MediaServerError> { + let connection = self.connection().await?; + let container = self.fetch_catalog_page(&connection, library, page, Some(3), true).await?; + let upstream_item_count = container.upstream_item_count(); + let items = container + .directories + .iter() + .filter(|directory| directory.item_type.as_deref().is_none_or(|item_type| item_type.eq_ignore_ascii_case("season"))) + .filter_map(|directory| { + plex_directory_to_season(&self.config.input_name, &connection.status.server_id, &library.library_id, directory) + }) + .collect(); + Ok(MediaServerPage::with_upstream_item_count(page, container.total_size, upstream_item_count, items)) + } + + async fn list_episodes( + &self, + library: &MediaServerLibraryRef, + page: MediaServerPageRequest, + ) -> Result, MediaServerError> { + let connection = self.connection().await?; + let container = self.fetch_catalog_page(&connection, library, page, Some(4), false).await?; + let upstream_item_count = container.upstream_item_count(); + let items = container + .videos + .iter() + .filter_map(|video| plex_video_to_episode(&self.config.input_name, &connection.status.server_id, &library.library_id, video)) + .collect(); + Ok(MediaServerPage::with_upstream_item_count(page, container.total_size, upstream_item_count, items)) + } + + async fn open_stream( + &self, + stream_ref: &MediaServerStreamRef, + range: Option<&str>, + ) -> Result { + let MediaServerStreamRef::Plex { + input_name, + server_id, + rating_key, + part_key, + } = stream_ref else { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) + .provider("plex") + .detail("Plex stream open received a non-Plex stream ref")); + }; + if input_name.as_ref() != self.config.input_name.as_ref() { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound) + .provider("plex") + .detail("Plex stream ref did not belong to the selected input")); + } + if non_blank(rating_key.as_ref()).is_none() { + return Err(MediaServerError::new(MediaServerErrorKind::NoDirectPlayableMediaServerSource) + .provider("plex") + .detail("Plex stream ref is missing a stable rating key")); + } + + let connection = self.connection().await?; + if server_id.as_ref() != connection.status.server_id.as_ref() { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound) + .provider("plex") + .detail("Plex stream ref did not belong to the selected server")); + } + + let url = pms_part_url(&connection.base_url, part_key)?; + let token = HeaderValue::from_str(connection.token.as_ref()).map_err(|_| { + MediaServerError::new(MediaServerErrorKind::MediaServerAuthDenied) + .provider("plex") + .detail("Plex token contains invalid header characters") + })?; + let mut request = self + .http + .request(Method::GET, &url) + .playback_errors() + .header(X_PLEX_TOKEN, token); + if let Some(range) = range.and_then(non_blank) { + let range = HeaderValue::from_str(range).map_err(|_| { + MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) + .provider("plex") + .detail("Plex Range header contains invalid characters") + })?; + request = request.header(RANGE, range); + } + + let response = request.send_with_error_detail("Plex stream request failed").await?; + let status = response.status(); + if !status.is_success() { + return Err(PlexOperation::Playback + .status_error(status) + .detail(format!("Plex stream request returned {status}"))); + } + let headers = response.headers().clone(); + let body = response + .bytes_stream() + .map_err(|err| { + MediaServerError::from_reqwest_error_with_fallback( + &err, + PlexOperation::Playback.not_found_kind(), + PlexOperation::Playback.fallback_kind(), + ) + .provider("plex") + .detail("Plex stream body read failed") + }) + .boxed(); + Ok(MediaServerStreamResponse { status, headers, body }) + } + + async fn open_image(&self, image_ref: &MediaServerImageRef) -> Result { + let MediaServerImageRef::Plex { + input_name, + server_id, + rating_key, + image_path, + } = image_ref else { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) + .provider("plex") + .detail("Plex image open received a non-Plex image ref")); + }; + if input_name.as_ref() != self.config.input_name.as_ref() { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound) + .provider("plex") + .detail("Plex image ref did not belong to the selected input")); + } + if non_blank(rating_key.as_ref()).is_none() { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound) + .provider("plex") + .detail("Plex image ref is missing a stable rating key")); + } + + let connection = self.connection().await?; + if server_id.as_ref() != connection.status.server_id.as_ref() { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound) + .provider("plex") + .detail("Plex image ref did not belong to the selected server")); + } + + let url = pms_image_url(&connection.base_url, image_path)?; + let response: reqwest::Response = self.get_response(&url, &connection.token, PlexOperation::Playback).await?; + let status = response.status(); + let headers = response.headers().clone(); + let body = response.bytes().await.map_err(|err| { + MediaServerError::from_reqwest_error_with_fallback( + &err, + PlexOperation::Playback.not_found_kind(), + PlexOperation::Playback.fallback_kind(), + ) + .provider("plex") + .detail("Plex image body read failed") + })?; + Ok(MediaServerResourceResponse { status, headers, body }) + } +} + +#[derive(Debug, Copy, Clone)] +enum PlexOperation { + Discovery, + Catalog, + Playback, +} + +impl PlexOperation { + const fn not_found_kind(self) -> MediaServerErrorKind { + match self { + Self::Discovery => MediaServerErrorKind::MediaServerUnavailable, + Self::Catalog => MediaServerErrorKind::MediaServerLibraryUnavailable, + Self::Playback => MediaServerErrorKind::MediaServerItemNotFound, + } + } + + const fn fallback_kind(self) -> MediaServerErrorKind { + match self { + Self::Discovery => MediaServerErrorKind::MediaServerDiscoveryFailed, + Self::Catalog => MediaServerErrorKind::MediaServerCatalogDecodeFailed, + Self::Playback => MediaServerErrorKind::MediaServerStreamOpenFailed, + } + } + + fn status_error(self, status: StatusCode) -> MediaServerError { + MediaServerError::from_http_status_with_fallback(status, self.not_found_kind(), self.fallback_kind()).provider("plex") + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename = "MediaContainer")] +struct PlexIdentityDto { + #[serde(rename = "@machineIdentifier")] + machine_identifier: Option, + #[serde(rename = "@friendlyName")] + friendly_name: Option, + #[serde(rename = "@version")] + version: Option, +} + +impl PlexIdentityDto { + fn into_status(self, fallback_server_id: Option<&str>, owned: Option) -> Result { + let server_id = self + .machine_identifier + .as_deref() + .and_then(non_blank) + .or_else(|| fallback_server_id.and_then(non_blank)) + .map(Arc::::from) + .ok_or_else(|| { + MediaServerError::new(MediaServerErrorKind::MediaServerDiscoveryFailed) + .provider("plex") + .detail("Plex PMS identity did not expose a stable machine identifier") + })?; + Ok(MediaServerStatus { + kind: MediaServerKind::Plex, + server_id, + display_name: self.friendly_name.as_deref().and_then(non_blank).map(Arc::::from), + version: self.version.as_deref().and_then(non_blank).map(Arc::::from), + owned, + }) + } +} + +impl PlexMediaContainerDto { + fn upstream_item_count(&self) -> usize { + self.size.unwrap_or_else(|| self.videos.len().saturating_add(self.directories.len())) + } +} + +fn select_libraries( + config: &PlexClientConfig, + server_id: &Arc, + sections: &[PlexSectionDto], +) -> Result, MediaServerError> { + let mut libraries = Vec::new(); + for selector in &config.libraries { + let matches = sections.iter().filter(|section| plex_section_matches_selector(section, selector)).collect::>(); + match matches.as_slice() { + [] => { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerLibraryUnavailable) + .provider("plex") + .detail("selected Plex library was not visible")); + } + [section] => { + let library = plex_section_to_library(&config.input_name, server_id, section).ok_or_else(|| { + MediaServerError::new(MediaServerErrorKind::MediaServerLibraryUnavailable) + .provider("plex") + .detail("selected Plex library did not expose a stable key") + })?; + libraries.push(library); + } + _ => { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerLibraryUnavailable) + .provider("plex") + .detail("selected Plex library name was ambiguous")); + } + } + } + Ok(libraries) +} + +fn select_resource<'a>( + resources: &'a [crate::media_server::plex::dto::PlexResourceDto], + config: &PlexClientConfig, +) -> Result<&'a crate::media_server::plex::dto::PlexResourceDto, MediaServerError> { + let plex_servers = resources + .iter() + .filter(|resource| { + resource + .product + .as_deref() + .is_some_and(|product| product.eq_ignore_ascii_case("Plex Media Server")) + }) + .collect::>(); + + let matches = if let Some(server_id) = config.server_id.as_deref() { + plex_servers.into_iter().filter(|resource| resource_matches_server_id(resource, server_id)).collect::>() + } else if let Some(server_name) = config.server_name.as_deref() { + plex_servers + .into_iter() + .filter(|resource| resource.name.as_deref().is_some_and(|value| value == server_name)) + .collect::>() + } else { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerDiscoveryFailed) + .provider("plex") + .detail("Plex resource discovery requires a server selector")); + }; + + match matches.as_slice() { + [] => Err(MediaServerError::new(MediaServerErrorKind::MediaServerDiscoveryFailed) + .provider("plex") + .detail("no Plex resource matched the configured selector")), + [resource] => Ok(resource), + _ => Err(MediaServerError::new(MediaServerErrorKind::MediaServerDiscoveryFailed) + .provider("plex") + .detail("configured Plex server selector matched multiple resources")), + } +} + +fn selected_connection_urls( + resource: &crate::media_server::plex::dto::PlexResourceDto, + prefer_https: bool, + allow_relay: bool, +) -> Result>, MediaServerError> { + let mut candidates = resource + .connections + .iter() + .filter_map(|connection| { + let uri = connection.uri.as_deref().and_then(non_blank).map(Arc::::from)?; + let parsed = Url::parse(&uri).ok()?; + let scheme = parsed.scheme(); + if !matches!(scheme, "http" | "https") { + return None; + } + let relay = connection.relay.unwrap_or_default() != 0; + if relay && !allow_relay { + return None; + } + Some((uri, relay, scheme == "https", connection.local.unwrap_or_default() != 0)) + }) + .collect::>(); + + candidates.sort_by_key(|(_, relay, https, local)| (*relay, https_sort_key(*https, prefer_https), !*local)); + let urls = candidates.into_iter().map(|(uri, _, _, _)| uri).collect::>(); + if urls.is_empty() { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerUnavailable) + .provider("plex") + .detail("selected Plex resource did not expose a usable PMS connection")); + } + Ok(urls) +} + +const fn https_sort_key(is_https: bool, prefer_https: bool) -> u8 { + if prefer_https && is_https { + 0 + } else if prefer_https { + 1 + } else { + 0 + } +} + +fn verify_direct_selectors(config: &PlexClientConfig, identity: &PlexIdentityDto) -> Result<(), MediaServerError> { + if let Some(server_id) = config.server_id.as_deref() { + verify_selector(identity.machine_identifier.as_deref(), server_id, "configured Plex server selector did not match PMS identity")?; + } + if let Some(server_name) = config.server_name.as_deref() { + verify_selector(identity.friendly_name.as_deref(), server_name, "configured Plex server name did not match PMS identity")?; + } + Ok(()) +} + +fn resource_matches_server_id(resource: &crate::media_server::plex::dto::PlexResourceDto, server_id: &str) -> bool { + // For Plex Media Server resources, MyPlex exposes the stable server identity as + // clientIdentifier. PMS /identity exposes the same identity as machineIdentifier. + resource.client_identifier.as_deref().is_some_and(|value| value == server_id) + || resource.machine_identifier.as_deref().is_some_and(|value| value == server_id) +} + +fn verify_resource_identity( + resource: &crate::media_server::plex::dto::PlexResourceDto, + identity: &PlexIdentityDto, +) -> Result<(), MediaServerError> { + let expected_server_id = resource.machine_identifier.as_deref().or(resource.client_identifier.as_deref()); + if let Some(expected_server_id) = expected_server_id { + verify_selector( + identity.machine_identifier.as_deref(), + expected_server_id, + "selected Plex resource identity did not match PMS identity", + )?; + } + Ok(()) +} + +fn verify_selector(actual: Option<&str>, expected: &str, detail: &'static str) -> Result<(), MediaServerError> { + if actual.is_some_and(|actual| actual == expected) { + Ok(()) + } else { + Err(MediaServerError::new(MediaServerErrorKind::MediaServerDiscoveryFailed) + .provider("plex") + .detail(detail)) + } +} + +fn pms_url(base_url: &Arc, path: &str) -> Result { + let base = Url::parse(base_url).map_err(|_| plex_invalid_pms_url())?; + let mut url = base; + url.set_path(path); + url.set_query(None); + Ok(url.to_string()) +} + +fn pms_part_url(base_url: &Arc, part_key: &str) -> Result { + let part_key = non_blank(part_key).ok_or_else(|| { + MediaServerError::new(MediaServerErrorKind::NoDirectPlayableMediaServerSource) + .provider("plex") + .detail("Plex stream ref is missing part_key") + })?; + if !part_key.starts_with("/library/parts/") || part_key.starts_with("//") || part_key.bytes().any(|byte| byte.is_ascii_control()) { + return Err(MediaServerError::new(MediaServerErrorKind::NoDirectPlayableMediaServerSource) + .provider("plex") + .detail("Plex part_key is not a direct part resource")); + } + + let base = Url::parse(base_url).map_err(|_| plex_invalid_pms_url())?; + let expected_origin = base.origin().ascii_serialization(); + let url = base.join(part_key).map_err(|_| { + MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) + .provider("plex") + .detail("Plex part_key could not be resolved against the selected PMS") + })?; + if url.origin().ascii_serialization() != expected_origin || url.fragment().is_some() { + return Err(MediaServerError::new(MediaServerErrorKind::NoDirectPlayableMediaServerSource) + .provider("plex") + .detail("Plex part_key did not resolve to the selected PMS origin")); + } + if !url.path().starts_with("/library/parts/") { + return Err(MediaServerError::new(MediaServerErrorKind::NoDirectPlayableMediaServerSource) + .provider("plex") + .detail("Plex part_key did not resolve to a direct part resource")); + } + if url.query_pairs().any(|(name, _)| is_sensitive_plex_part_query_name(&name)) { + return Err(MediaServerError::new(MediaServerErrorKind::NoDirectPlayableMediaServerSource) + .provider("plex") + .detail("Plex part_key must not carry credential query parameters")); + } + Ok(url.to_string()) +} + +fn pms_image_url(base_url: &Arc, image_path: &str) -> Result { + let image_path = non_blank(image_path).ok_or_else(|| { + MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound) + .provider("plex") + .detail("Plex image ref is missing image_path") + })?; + if !image_path.starts_with("/library/metadata/") + || image_path.starts_with("//") + || image_path.bytes().any(|byte| byte.is_ascii_control()) + { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound) + .provider("plex") + .detail("Plex image_path is not a metadata image resource")); + } + + let base = Url::parse(base_url).map_err(|_| plex_invalid_pms_url())?; + let expected_origin = base.origin().ascii_serialization(); + let url = base.join(image_path).map_err(|_| { + MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) + .provider("plex") + .detail("Plex image_path could not be resolved against the selected PMS") + })?; + if url.origin().ascii_serialization() != expected_origin || url.fragment().is_some() { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound) + .provider("plex") + .detail("Plex image_path did not resolve to the selected PMS origin")); + } + if !url.path().starts_with("/library/metadata/") { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound) + .provider("plex") + .detail("Plex image_path did not resolve to a metadata image resource")); + } + if url.query_pairs().any(|(name, _)| is_sensitive_plex_part_query_name(&name)) { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound) + .provider("plex") + .detail("Plex image_path must not carry credential query parameters")); + } + Ok(url.to_string()) +} + +fn is_sensitive_plex_part_query_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower == "x-plex-token" || lower == "token" || lower.ends_with("_token") || lower == "api_key" || lower == "apikey" +} + +fn plex_invalid_pms_url() -> MediaServerError { + MediaServerError::new(MediaServerErrorKind::MediaServerDiscoveryFailed) + .provider("plex") + .detail("Plex PMS URL is invalid") +} + +fn append_query_pair(url: &mut String, key: &str, value: &str) { + let separator = if url.contains('?') { '&' } else { '?' }; + url.push(separator); + url.push_str(key); + url.push('='); + url.push_str(value); +} + +fn encode_url_path_segment(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(byte as char); + } else { + let _ = write!(encoded, "%{byte:02X}"); + } + } + encoded +} + +fn non_blank(value: &str) -> Option<&str> { + let value = value.trim(); + (!value.is_empty()).then_some(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::media_server::plex::dto::{PlexConnectionDto, PlexResourceDto}; + use bytes::Bytes; + use futures::StreamExt; + use http_body_util::Full; + use hyper::{body::Incoming, http, service::service_fn, Request, Response}; + use hyper_util::{ + rt::{TokioExecutor, TokioIo}, + server::conn::auto::Builder, + }; + use shared::model::{MediaServerCatalogConfigDto, MediaServerImagePolicy, MediaServerLibrarySelectorDetailsDto, MediaServerPlaybackConfigDto}; + use std::{ + convert::Infallible, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc as StdArc, + }, + }; + use tokio::{net::TcpListener, sync::Mutex as TokioMutex}; + + const PLEX_TEST_TOKEN: &str = "pms-token-redacted"; + + #[derive(Default)] + struct PlexPlaybackServerState { + identity_requests: AtomicUsize, + stream_requests: AtomicUsize, + last_range: TokioMutex>, + last_uri: TokioMutex>, + } + + fn config() -> PlexClientConfig { + PlexClientConfig { + input_name: "media_server".into(), + direct_url: None, + token: None, + account_token: Some("account-token-redacted".into()), + server_id: Some("machine-redacted".into()), + server_name: None, + prefer_https: true, + allow_relay: false, + libraries: vec![MediaServerLibrarySelector::Name("Movies".to_string())], + } + } + + #[test] + fn server_id_selector_matches_myplex_machine_identifier_without_exposing_token() { + let resource = PlexResourceDto { + name: Some("Server Redacted".to_string()), + product: Some("Plex Media Server".to_string()), + product_version: None, + client_identifier: Some("resource-redacted".to_string()), + machine_identifier: Some("machine-redacted".to_string()), + owned: Some(0), + access_token: Some("resource-token-redacted".to_string()), + connections: Vec::new(), + }; + let resources = [resource]; + let selected = select_resource(&resources, &config()).expect("resource selected"); + + assert_eq!(selected.machine_identifier.as_deref(), Some("machine-redacted")); + } + + #[test] + fn server_id_selector_matches_myplex_client_identifier_and_verifies_pms_identity() { + let config = config(); + let resource = PlexResourceDto { + name: Some("Server Redacted".to_string()), + product: Some("Plex Media Server".to_string()), + product_version: None, + client_identifier: Some("machine-redacted".to_string()), + machine_identifier: None, + owned: Some(1), + access_token: Some("resource-token-redacted".to_string()), + connections: Vec::new(), + }; + let resources = [resource]; + let selected = select_resource(&resources, &config).expect("resource selected by server_id"); + let identity = PlexIdentityDto { + machine_identifier: Some("machine-redacted".to_string()), + friendly_name: Some("PMS Redacted".to_string()), + version: Some("1.0".to_string()), + }; + + verify_resource_identity(selected, &identity).expect("MyPlex resource matches PMS identity"); + let status = identity + .into_status(selected.client_identifier.as_deref(), selected.owned.map(|owned| owned != 0)) + .expect("status maps"); + + assert_eq!(status.server_id.as_ref(), "machine-redacted"); + assert_eq!(status.owned, Some(true)); + } + + #[test] + fn selected_resource_identity_rejects_client_identifier_mismatch() { + let resource = PlexResourceDto { + name: Some("Server Redacted".to_string()), + product: Some("Plex Media Server".to_string()), + product_version: None, + client_identifier: Some("resource-redacted".to_string()), + machine_identifier: None, + owned: Some(0), + access_token: Some("resource-token-redacted".to_string()), + connections: Vec::new(), + }; + let identity = PlexIdentityDto { + machine_identifier: Some("machine-redacted".to_string()), + friendly_name: Some("PMS Redacted".to_string()), + version: Some("1.0".to_string()), + }; + + let error = verify_resource_identity(&resource, &identity).expect_err("identity mismatch is rejected"); + + assert_eq!(error.kind, MediaServerErrorKind::MediaServerDiscoveryFailed); + } + + #[test] + fn rejects_ambiguous_plex_resource_name() { + let mut config = config(); + config.server_id = None; + config.server_name = Some("Server Redacted".into()); + let resource = PlexResourceDto { + name: Some("Server Redacted".to_string()), + product: Some("Plex Media Server".to_string()), + product_version: None, + client_identifier: Some("resource-redacted".to_string()), + machine_identifier: Some("machine-redacted".to_string()), + owned: Some(0), + access_token: Some("resource-token-redacted".to_string()), + connections: Vec::new(), + }; + + let error = select_resource(&[resource.clone(), resource], &config).expect_err("name is ambiguous"); + assert_eq!(error.kind, MediaServerErrorKind::MediaServerDiscoveryFailed); + } + + #[test] + fn connection_selection_rejects_relay_by_default_and_prefers_https() { + let resource = PlexResourceDto { + name: Some("Server Redacted".to_string()), + product: Some("Plex Media Server".to_string()), + product_version: None, + client_identifier: Some("resource-redacted".to_string()), + machine_identifier: Some("machine-redacted".to_string()), + owned: Some(0), + access_token: Some("resource-token-redacted".to_string()), + connections: vec![ + PlexConnectionDto { + protocol: Some("http".to_string()), + uri: Some("http://pms.example.invalid".to_string()), + local: Some(0), + relay: Some(0), + }, + PlexConnectionDto { + protocol: Some("https".to_string()), + uri: Some("https://pms.example.invalid".to_string()), + local: Some(0), + relay: Some(0), + }, + PlexConnectionDto { + protocol: Some("https".to_string()), + uri: Some("https://relay.example.invalid".to_string()), + local: Some(0), + relay: Some(1), + }, + ], + }; + + let urls = selected_connection_urls(&resource, true, false).expect("usable connection"); + assert_eq!(urls.iter().map(AsRef::as_ref).collect::>(), vec!["https://pms.example.invalid", "http://pms.example.invalid"]); + } + + #[test] + fn selected_libraries_reject_duplicate_title_selectors() { + let sections = vec![ + PlexSectionDto { key: Some("1".to_string()), title: Some("Movies".to_string()), section_type: Some("movie".to_string()) }, + PlexSectionDto { key: Some("2".to_string()), title: Some("Movies".to_string()), section_type: Some("movie".to_string()) }, + ]; + let error = select_libraries(&config(), &"machine-redacted".into(), §ions).expect_err("duplicate title"); + + assert_eq!(error.kind, MediaServerErrorKind::MediaServerLibraryUnavailable); + } + + #[test] + fn selected_libraries_allow_stable_key_selector() { + let mut config = config(); + config.libraries = vec![MediaServerLibrarySelector::Detailed(MediaServerLibrarySelectorDetailsDto { + key: Some("2".to_string()), + ..MediaServerLibrarySelectorDetailsDto::default() + })]; + let sections = vec![ + PlexSectionDto { key: Some("1".to_string()), title: Some("Movies".to_string()), section_type: Some("movie".to_string()) }, + PlexSectionDto { key: Some("2".to_string()), title: Some("Movies".to_string()), section_type: Some("movie".to_string()) }, + ]; + let libraries = select_libraries(&config, &"machine-redacted".into(), §ions).expect("stable key disambiguates"); + + assert_eq!(libraries[0].reference.library_id.as_ref(), "2"); + } + + #[test] + fn pms_part_url_accepts_only_same_origin_part_resources() { + let base = StdArc::::from("http://127.0.0.1:32400/base"); + + assert_eq!( + pms_part_url(&base, "/library/parts/part-redacted/file.mkv?download=1").expect("part key resolves"), + "http://127.0.0.1:32400/library/parts/part-redacted/file.mkv?download=1" + ); + assert_eq!( + pms_part_url(&base, "/library/metadata/rating-redacted").expect_err("metadata paths are not direct part refs").kind, + MediaServerErrorKind::NoDirectPlayableMediaServerSource + ); + assert_eq!( + pms_part_url(&base, "//evil.example.invalid/library/parts/part-redacted/file.mkv") + .expect_err("network-path refs must not escape the selected PMS") + .kind, + MediaServerErrorKind::NoDirectPlayableMediaServerSource + ); + assert_eq!( + pms_part_url(&base, "/library/parts/../../identity") + .expect_err("normalized paths must stay under direct part refs") + .kind, + MediaServerErrorKind::NoDirectPlayableMediaServerSource + ); + assert_eq!( + pms_part_url(&base, "/library/parts/part-redacted/file.mkv?X-Plex-Token=should-not-leak") + .expect_err("part refs must not carry credentials") + .kind, + MediaServerErrorKind::NoDirectPlayableMediaServerSource + ); + } + + fn plex_test_response(status: StatusCode, body: &'static [u8]) -> Response> { + Response::builder().status(status).body(Full::new(Bytes::from_static(body))).expect("response builds") + } + + fn plex_test_media_server_config() -> MediaServerInputConfig { + MediaServerInputConfig { + libraries: Vec::new(), + catalog: MediaServerCatalogConfigDto::default(), + playback: MediaServerPlaybackConfigDto::default(), + image_policy: MediaServerImagePolicy::default(), + token: Some(PLEX_TEST_TOKEN.to_string()), + api_key: None, + user_id: None, + account_token: None, + server_id: None, + server_name: None, + prefer_https: true, + allow_relay: false, + } + } + + fn plex_test_token_is_valid(req: &Request) -> bool { + req.headers() + .get(X_PLEX_TOKEN) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == PLEX_TEST_TOKEN) + } + + async fn plex_playback_handler( + req: Request, + state: StdArc, + ) -> Result>, Infallible> { + if !plex_test_token_is_valid(&req) { + return Ok(plex_test_response(StatusCode::UNAUTHORIZED, b"")); + } + + match req.uri().path() { + "/identity" => { + state.identity_requests.fetch_add(1, Ordering::SeqCst); + Ok(plex_test_response( + StatusCode::OK, + br#""#, + )) + } + "/library/parts/part-redacted/file.mkv" => { + state.stream_requests.fetch_add(1, Ordering::SeqCst); + *state.last_uri.lock().await = Some(req.uri().to_string()); + *state.last_range.lock().await = req + .headers() + .get(http::header::RANGE) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned); + + Ok(Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(http::header::CONTENT_TYPE, "video/x-matroska") + .header(http::header::CONTENT_RANGE, "bytes 5-8/9") + .header(http::header::CONTENT_LENGTH, "4") + .header(http::header::ACCEPT_RANGES, "bytes") + .body(Full::new(Bytes::from_static(b"data"))) + .expect("response builds")) + } + "/library/metadata/rating-redacted/thumb" => Ok(Response::builder() + .status(StatusCode::OK) + .header(http::header::CONTENT_TYPE, "image/jpeg") + .header(http::header::CONTENT_LENGTH, "4") + .body(Full::new(Bytes::from_static(b"jpeg"))) + .expect("response builds")), + _ => Ok(plex_test_response(StatusCode::NOT_FOUND, b"")), + } + } + + async fn start_plex_playback_server(state: StdArc) -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("test server binds"); + let addr = listener.local_addr().expect("local addr"); + let handle = tokio::spawn(async move { + loop { + let Ok((socket, _)) = listener.accept().await else { continue }; + let state = StdArc::clone(&state); + tokio::spawn(async move { + let io = TokioIo::new(socket); + let service = service_fn(move |req| plex_playback_handler(req, StdArc::clone(&state))); + let builder = Builder::new(TokioExecutor::new()); + let _ = builder.serve_connection(io, service).await; + }); + } + }); + (format!("http://{addr}"), handle) + } + + #[tokio::test] + async fn open_stream_proxies_plex_part_key_with_token_header_and_range() { + let state = StdArc::new(PlexPlaybackServerState::default()); + let (base_url, server) = start_plex_playback_server(StdArc::clone(&state)).await; + let media_server = plex_test_media_server_config(); + let client = PlexCatalogClient::new( + "media_server".into(), + &base_url, + &media_server, + MediaServerHttpClient::new(reqwest::Client::new()), + ); + let stream_ref = MediaServerStreamRef::Plex { + input_name: "media_server".into(), + server_id: "machine-redacted".into(), + rating_key: "rating-redacted".into(), + part_key: "/library/parts/part-redacted/file.mkv?download=1".into(), + }; + + let response = client + .open_stream(&stream_ref, Some("bytes=5-8")) + .await + .expect("Plex stream opens"); + + assert_eq!(response.status, StatusCode::PARTIAL_CONTENT); + assert_eq!(response.headers.get(http::header::CONTENT_RANGE).and_then(|value| value.to_str().ok()), Some("bytes 5-8/9")); + let chunks = response.body.collect::>().await; + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].as_ref().map(Bytes::as_ref), Ok(b"data".as_slice())); + assert_eq!(state.stream_requests.load(Ordering::SeqCst), 1); + assert_eq!(state.last_range.lock().await.as_deref(), Some("bytes=5-8")); + assert_eq!( + state.last_uri.lock().await.as_deref(), + Some("/library/parts/part-redacted/file.mkv?download=1") + ); + assert!(!state.last_uri.lock().await.as_deref().unwrap_or_default().contains(PLEX_TEST_TOKEN)); + assert!(!state.last_uri.lock().await.as_deref().unwrap_or_default().contains("X-Plex-Token")); + + server.abort(); + } + + #[tokio::test] + async fn open_image_proxies_plex_metadata_image_with_token_header() { + let state = StdArc::new(PlexPlaybackServerState::default()); + let (base_url, server) = start_plex_playback_server(StdArc::clone(&state)).await; + let media_server = plex_test_media_server_config(); + let client = PlexCatalogClient::new( + "media_server".into(), + &base_url, + &media_server, + MediaServerHttpClient::new(reqwest::Client::new()), + ); + let image_ref = MediaServerImageRef::Plex { + input_name: "media_server".into(), + server_id: "machine-redacted".into(), + rating_key: "rating-redacted".into(), + image_path: "/library/metadata/rating-redacted/thumb".into(), + }; + + let response = client.open_image(&image_ref).await.expect("Plex image opens"); + + assert_eq!(response.status, StatusCode::OK); + assert_eq!(response.headers.get(http::header::CONTENT_TYPE).and_then(|value| value.to_str().ok()), Some("image/jpeg")); + assert_eq!(response.body.as_ref(), b"jpeg"); + + server.abort(); + } + + #[tokio::test] + async fn direct_playback_reuses_discovered_connection_across_client_instances() { + let state = StdArc::new(PlexPlaybackServerState::default()); + let (base_url, server) = start_plex_playback_server(StdArc::clone(&state)).await; + let media_server = plex_test_media_server_config(); + let stream_ref = MediaServerStreamRef::Plex { + input_name: "media_server".into(), + server_id: "machine-redacted".into(), + rating_key: "rating-redacted".into(), + part_key: "/library/parts/part-redacted/file.mkv?download=1".into(), + }; + + for _ in 0..2 { + let client = PlexCatalogClient::new( + "media_server".into(), + &base_url, + &media_server, + MediaServerHttpClient::new(reqwest::Client::new()), + ); + let response = client.open_stream(&stream_ref, None).await.expect("Plex stream opens"); + let chunks = response.body.collect::>().await; + assert_eq!(chunks[0].as_ref().map(Bytes::as_ref), Ok(b"data".as_slice())); + } + + assert_eq!(state.stream_requests.load(Ordering::SeqCst), 2); + assert_eq!(state.identity_requests.load(Ordering::SeqCst), 1); + + server.abort(); + } +} diff --git a/backend/src/media_server/plex/dto.rs b/backend/src/media_server/plex/dto.rs index 9c07eca9e..298595fe8 100644 --- a/backend/src/media_server/plex/dto.rs +++ b/backend/src/media_server/plex/dto.rs @@ -79,10 +79,64 @@ pub struct PlexDirectoryDto { pub item_type: Option, #[serde(rename = "@title")] pub title: Option, + #[serde(rename = "@titleSort")] + pub title_sort: Option, + #[serde(rename = "@originalTitle")] + pub original_title: Option, #[serde(rename = "@year")] pub year: Option, + #[serde(rename = "@originallyAvailableAt")] + pub originally_available_at: Option, + #[serde(rename = "@summary")] + pub summary: Option, + #[serde(rename = "@tagline")] + pub tagline: Option, + #[serde(rename = "@studio")] + pub studio: Option, + #[serde(rename = "@contentRating")] + pub content_rating: Option, + #[serde(rename = "@contentRatingAge")] + pub content_rating_age: Option, + #[serde(rename = "@audienceRating")] + pub audience_rating: Option, + #[serde(rename = "@guid")] + pub guid: Option, + #[serde(rename = "@thumb")] + pub thumb: Option, + #[serde(rename = "@art")] + pub art: Option, + #[serde(rename = "@theme")] + pub theme: Option, + #[serde(rename = "@parentRatingKey")] + pub parent_rating_key: Option, + #[serde(rename = "@parentTitle")] + pub parent_title: Option, + #[serde(rename = "@parentGuid")] + pub parent_guid: Option, + #[serde(rename = "@parentIndex")] + pub parent_index: Option, + #[serde(rename = "@index")] + pub index: Option, + #[serde(rename = "@childCount")] + pub child_count: Option, + #[serde(rename = "@leafCount")] + pub leaf_count: Option, + #[serde(rename = "@viewedLeafCount")] + pub viewed_leaf_count: Option, + #[serde(rename = "@addedAt")] + pub added_at: Option, + #[serde(rename = "@updatedAt")] + pub updated_at: Option, #[serde(rename = "Guid", default)] pub guids: Vec, + #[serde(rename = "Genre", default)] + pub genres: Vec, + #[serde(rename = "Country", default)] + pub countries: Vec, + #[serde(rename = "Role", default)] + pub roles: Vec, + #[serde(rename = "Image", default)] + pub images: Vec, } #[derive(Debug, Clone, Deserialize, PartialEq)] @@ -95,8 +149,26 @@ pub struct PlexVideoDto { pub item_type: Option, #[serde(rename = "@title")] pub title: Option, + #[serde(rename = "@titleSort")] + pub title_sort: Option, + #[serde(rename = "@originalTitle")] + pub original_title: Option, #[serde(rename = "@year")] pub year: Option, + #[serde(rename = "@originallyAvailableAt")] + pub originally_available_at: Option, + #[serde(rename = "@summary")] + pub summary: Option, + #[serde(rename = "@tagline")] + pub tagline: Option, + #[serde(rename = "@studio")] + pub studio: Option, + #[serde(rename = "@contentRating")] + pub content_rating: Option, + #[serde(rename = "@contentRatingAge")] + pub content_rating_age: Option, + #[serde(rename = "@audienceRating")] + pub audience_rating: Option, #[serde(rename = "@guid")] pub guid: Option, #[serde(rename = "@thumb")] @@ -105,8 +177,14 @@ pub struct PlexVideoDto { pub art: Option, #[serde(rename = "@parentRatingKey")] pub parent_rating_key: Option, + #[serde(rename = "@parentGuid")] + pub parent_guid: Option, + #[serde(rename = "@parentTitle")] + pub parent_title: Option, #[serde(rename = "@grandparentRatingKey")] pub grandparent_rating_key: Option, + #[serde(rename = "@grandparentGuid")] + pub grandparent_guid: Option, #[serde(rename = "@grandparentTitle")] pub grandparent_title: Option, #[serde(rename = "@parentIndex")] @@ -119,6 +197,18 @@ pub struct PlexVideoDto { pub updated_at: Option, #[serde(rename = "Guid", default)] pub guids: Vec, + #[serde(rename = "Genre", default)] + pub genres: Vec, + #[serde(rename = "Country", default)] + pub countries: Vec, + #[serde(rename = "Director", default)] + pub directors: Vec, + #[serde(rename = "Writer", default)] + pub writers: Vec, + #[serde(rename = "Role", default)] + pub roles: Vec, + #[serde(rename = "Image", default)] + pub images: Vec, #[serde(rename = "Media", default)] pub media: Vec, } @@ -129,6 +219,33 @@ pub struct PlexGuidDto { pub id: Option, } +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct PlexTagDto { + #[serde(rename = "@tag")] + pub tag: Option, + #[serde(rename = "@title")] + pub title: Option, +} + +impl PlexTagDto { + pub fn value(&self) -> Option> { + self.tag + .as_deref() + .or(self.title.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(std::sync::Arc::::from) + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct PlexImageDto { + #[serde(rename = "@type")] + pub image_type: Option, + #[serde(rename = "@url")] + pub url: Option, +} + #[derive(Debug, Clone, Deserialize, PartialEq)] pub struct PlexMediaDto { #[serde(rename = "@id")] @@ -143,6 +260,14 @@ pub struct PlexMediaDto { pub width: Option, #[serde(rename = "@height")] pub height: Option, + #[serde(rename = "@audioChannels")] + pub audio_channels: Option, + #[serde(rename = "@audioCodec")] + pub audio_codec: Option, + #[serde(rename = "@videoCodec")] + pub video_codec: Option, + #[serde(rename = "@videoResolution")] + pub video_resolution: Option, #[serde(rename = "Part", default)] pub parts: Vec, } @@ -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); } } diff --git a/backend/src/media_server/plex/mapper.rs b/backend/src/media_server/plex/mapper.rs new file mode 100644 index 000000000..d7c8638bb --- /dev/null +++ b/backend/src/media_server/plex/mapper.rs @@ -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, + server_id: &Arc, + section: &PlexSectionDto, +) -> Option { + 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, + server_id: &Arc, + library_id: &Arc, + video: &PlexVideoDto, +) -> Option { + let rating_key = non_blank(video.rating_key.as_deref())?; + let title = non_blank(video.title.as_deref()).unwrap_or_else(|| Arc::::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, + server_id: &Arc, + library_id: &Arc, + directory: &PlexDirectoryDto, +) -> Option { + let rating_key = non_blank(directory.rating_key.as_deref())?; + let title = non_blank(directory.title.as_deref()).unwrap_or_else(|| Arc::::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, + server_id: &Arc, + library_id: &Arc, + directory: &PlexDirectoryDto, +) -> Option { + let rating_key = non_blank(directory.rating_key.as_deref())?; + let title = non_blank(directory.title.as_deref()).unwrap_or_else(|| Arc::::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, + server_id: &Arc, + library_id: &Arc, + video: &PlexVideoDto, +) -> Option { + let rating_key = non_blank(video.rating_key.as_deref())?; + let title = non_blank(video.title.as_deref()).unwrap_or_else(|| Arc::::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) { + ( + 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, + server_id: &Arc, + rating_key: &Arc, + media: Option<&PlexMediaDto>, +) -> Option { + 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> { + media.parts.iter().find_map(|part| non_blank(part.key.as_deref())) +} + +fn plex_image_ref( + input_name: &Arc, + server_id: &Arc, + rating_key: &Arc, + candidates: [&Option; N], +) -> Option { + 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 { + 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 { + 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 { + 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> { + 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) -> Option { + 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 { + bitrate_kbps.checked_mul(1000).filter(|value| *value > 0) +} + +fn provider_hints(guid: Option<&str>, guids: &[PlexGuidDto]) -> Vec { + 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, Arc)> { + 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::::from(id))) +} + +fn normalize_guid_namespace(namespace: &str) -> Option> { + 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::::from(namespace)) +} + +fn tag_values(tags: &[crate::media_server::plex::dto::PlexTagDto]) -> Vec> { + tags.iter().filter_map(crate::media_server::plex::dto::PlexTagDto::value).collect() +} + +fn non_blank(value: Option<&str>) -> Option> { + value.map(str::trim).filter(|value| !value.is_empty()).map(Arc::::from) +} + +fn source_version_hint(updated_at: Option, added_at: Option) -> Option> { + updated_at.or(added_at).filter(|value| *value > 0).map(|value| Arc::::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 { Arc::::from("media_server") } + fn server_id() -> Arc { Arc::::from("machine-redacted") } + fn library_id() -> Arc { Arc::::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!["Drama"]); + assert_eq!(descriptive.directors.iter().map(AsRef::as_ref).collect::>(), vec!["Director Redacted"]); + assert_eq!(descriptive.cast.iter().map(AsRef::as_ref).collect::>(), 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!["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, width: Option, height: Option) -> 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![ + ("tmdb", "123"), + ("imdb", "tt-redacted"), + ("tmdb", "456"), + ("tvdb", "789") + ] + ); + } +} diff --git a/backend/src/media_server/plex/mod.rs b/backend/src/media_server/plex/mod.rs index a07dce5c0..9f328327a 100644 --- a/backend/src/media_server/plex/mod.rs +++ b/backend/src/media_server/plex/mod.rs @@ -1 +1,3 @@ +pub mod client; pub mod dto; +pub mod mapper; diff --git a/backend/src/media_server/test_fixtures.rs b/backend/src/media_server/test_fixtures.rs index 111a516b9..66b113dad 100644 --- a/backend/src/media_server/test_fixtures.rs +++ b/backend/src/media_server/test_fixtures.rs @@ -55,11 +55,50 @@ pub const PLEX_SECTIONS_XML: &str = r#" pub const PLEX_MOVIES_XML: &str = r#" - "#; + +pub const PLEX_SHOWS_XML: &str = r#" + + + + + + + + + + +"#; + +pub const PLEX_SEASONS_XML: &str = r#" + + + + + + +"#; + +pub const PLEX_EPISODES_XML: &str = r#" + + + +"#; diff --git a/backend/src/media_server/types.rs b/backend/src/media_server/types.rs index f4b31cd05..d575fdea8 100644 --- a/backend/src/media_server/types.rs +++ b/backend/src/media_server/types.rs @@ -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, } -#[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, diff --git a/backend/src/model/config/api_proxy.rs b/backend/src/model/config/api_proxy.rs index 0b050f83d..e4f90b292 100644 --- a/backend/src/model/config/api_proxy.rs +++ b/backend/src/model/config/api_proxy.rs @@ -102,10 +102,7 @@ impl From<&ApiProxyConfig> for ApiProxyConfigDto { fn serialize_api_proxy_config(config: &ApiProxyConfigDto) -> Result { 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) diff --git a/backend/src/model/config/input.rs b/backend/src/model/config/input.rs index e31f6d8ab..b2d8167f5 100644 --- a/backend/src/model/config/input.rs +++ b/backend/src/model/config/input.rs @@ -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 = #[derive(Debug, Clone)] pub struct MediaServerInputConfig { - pub libraries: Vec, + pub libraries: Vec, pub catalog: MediaServerCatalogConfigDto, pub playback: MediaServerPlaybackConfigDto, - pub image_policy: MediaServerImagePolicyDto, + pub image_policy: MediaServerImagePolicy, pub token: Option, pub api_key: Option, pub user_id: Option, pub account_token: Option, pub server_id: Option, - pub machine_id: Option, pub server_name: Option, 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, diff --git a/backend/src/model/xtream.rs b/backend/src/model/xtream.rs index 86681a7e8..ab3518160 100644 --- a/backend/src/model/xtream.rs +++ b/backend/src/model/xtream.rs @@ -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::() { + 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); } diff --git a/backend/src/processing/processor/library.rs b/backend/src/processing/processor/library.rs index 1565cbf01..ad32139ae 100644 --- a/backend/src/processing/processor/library.rs +++ b/backend/src/processing/processor/library.rs @@ -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(), diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index e69e517ee..68f329316 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -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, Vec) { + 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, 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 { diff --git a/backend/src/processing/processor/sort.rs b/backend/src/processing/processor/sort.rs index 70f70506e..fb075d956 100644 --- a/backend/src/processing/processor/sort.rs +++ b/backend/src/processing/processor/sort.rs @@ -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, diff --git a/backend/src/processing/processor/stream_probe.rs b/backend/src/processing/processor/stream_probe.rs index 44e0b16c2..c7108f209 100644 --- a/backend/src/processing/processor/stream_probe.rs +++ b/backend/src/processing/processor/stream_probe.rs @@ -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(), diff --git a/backend/src/repository/library_repository.rs b/backend/src/repository/library_repository.rs index 2d48ab157..eb73e1e3c 100644 --- a/backend/src/repository/library_repository.rs +++ b/backend/src/repository/library_repository.rs @@ -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(), diff --git a/backend/src/repository/playlist_repository.rs b/backend/src/repository/playlist_repository.rs index c403b2668..2028959c5 100644 --- a/backend/src/repository/playlist_repository.rs +++ b/backend/src/repository/playlist_repository.rs @@ -232,32 +232,32 @@ fn media_server_series_episode_detail(header: &PlaylistItemHeader) -> Option 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::, Vec>::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] diff --git a/backend/src/runtime_config_report.rs b/backend/src/runtime_config_report.rs index df4a9993e..49095111d 100644 --- a/backend/src/runtime_config_report.rs +++ b/backend/src/runtime_config_report.rs @@ -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) diff --git a/backend/src/utils/file/config_reader.rs b/backend/src/utils/file/config_reader.rs index 183568e34..9ceafb919 100644 --- a/backend/src/utils/file/config_reader.rs +++ b/backend/src/utils/file/config_reader.rs @@ -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}")))?; diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 748ba2ccf..53626bfb5 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -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" diff --git a/frontend/src/app/components/config/log_config_view.rs b/frontend/src/app/components/config/log_config_view.rs index 88edff327..ce138cf77 100644 --- a/frontend/src/app/components/config/log_config_view.rs +++ b/frontend/src/app/components/config/log_config_view.rs @@ -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::>()); let runtime_report_format_options = use_memo((), |_| { - enum_iterator::all::() - .collect::>() - .iter() - .map(ToString::to_string) - .collect::>() + RuntimeConfigReportFormat::iter().collect::>().iter().map(ToString::to_string).collect::>() }); let form_state: UseReducerHandle = diff --git a/frontend/src/app/components/config/reverse_proxy_config_view.rs b/frontend/src/app/components/config/reverse_proxy_config_view.rs index 3011c2b00..3d73bf68a 100644 --- a/frontend/src/app/components/config/reverse_proxy_config_view.rs +++ b/frontend/src/app/components/config/reverse_proxy_config_view.rs @@ -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> { - Rc::new(all::().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::() { - 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); } } diff --git a/frontend/src/app/components/config/webui_config_view.rs b/frontend/src/app/components/config/webui_config_view.rs index 7848ecc1b..705bb6185 100644 --- a/frontend/src/app/components/config/webui_config_view.rs +++ b/frontend/src/app/components/config/webui_config_view.rs @@ -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::iter() .collect::>() .iter() .map(|view_type| DropDownOption { diff --git a/frontend/src/app/components/playlist/target/target_sort.rs b/frontend/src/app/components/playlist/target/target_sort.rs index 1a691ee95..d61bfb926 100644 --- a/frontend/src/app/components/playlist/target/target_sort.rs +++ b/frontend/src/app/components/playlist/target/target_sort.rs @@ -33,7 +33,7 @@ pub fn TargetSort(props: &TargetSortProps) -> Html {
{ translator.t("LABEL.FIELD") } - { rule.field.as_str() } + { rule.field.to_string() }
{ translator.t("LABEL.ORDER") } diff --git a/frontend/src/app/components/userlist/proxy_user_credentials_form.rs b/frontend/src/app/components/userlist/proxy_user_credentials_form.rs index a1f9337d3..8cd9cf4a1 100644 --- a/frontend/src/app/components/userlist/proxy_user_credentials_form.rs +++ b/frontend/src/app/components/userlist/proxy_user_credentials_form.rs @@ -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::iter() .map(|s| DropDownOption { id: s.to_string(), label: html! { }, diff --git a/shared/Cargo.toml b/shared/Cargo.toml index a23f6cc50..61683838b 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -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" diff --git a/shared/src/foundation/filter.rs b/shared/src/foundation/filter.rs index 20b8dbe5d..bf4815059 100644 --- a/shared/src/foundation/filter.rs +++ b/shared/src/foundation/filter.rs @@ -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) -> Result { if expr.as_rule() == Rule::field { let field_text = expr.as_str(); - for item in all::() { + for item in ItemField::iter() { if field_text.eq_ignore_ascii_case(item.to_string().as_str()) { return Ok(item); } diff --git a/shared/src/model/config/geoip.rs b/shared/src/model/config/geoip.rs index fe4cf81be..014a9fb6c 100644 --- a/shared/src/model/config/geoip.rs +++ b/shared/src/model/config/geoip.rs @@ -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 { - 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) } diff --git a/shared/src/model/config/input.rs b/shared/src/model/config/input.rs index ad196bd9d..8603ecdc8 100644 --- a/shared/src/model/config/input.rs +++ b/shared/src/model/config/input.rs @@ -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 { - 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 { - 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); } diff --git a/shared/src/model/config/log.rs b/shared/src/model/config/log.rs index 482ae86b1..38764025d 100644 --- a/shared/src/model/config/log.rs +++ b/shared/src/model/config/log.rs @@ -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 { - 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 { diff --git a/shared/src/model/config/media_server_catalog.rs b/shared/src/model/config/media_server_catalog.rs index d3eddfc67..3ca5fd26b 100644 --- a/shared/src/model/config/media_server_catalog.rs +++ b/shared/src/model/config/media_server_catalog.rs @@ -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, - #[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, #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub kind: Option, + pub kind: Option, } 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, + pub libraries: Vec, #[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, #[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, #[serde(default, skip_serializing_if = "is_blank_optional_string")] - pub machine_id: Option, - #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub server_name: Option, #[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::(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::(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::( @@ -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(); diff --git a/shared/src/model/config/proxy_user_status.rs b/shared/src/model/config/proxy_user_status.rs index 39c6ea6b5..91a4a0696 100644 --- a/shared/src/model/config/proxy_user_status.rs +++ b/shared/src/model/config/proxy_user_status.rs @@ -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 { - 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))), - } - } -} diff --git a/shared/src/model/item_field.rs b/shared/src/model/item_field.rs index c3593a9d1..3cf5b3967 100644 --- a/shared/src/model/item_field.rs +++ b/shared/src/model/item_field.rs @@ -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 { - 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(deserializer: D) -> Result 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::("\"Group\"").expect("legacy field casing parses"), + ItemField::Group + ); + assert_eq!(serde_json::from_str::("\"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\""); + } +} diff --git a/shared/src/model/mapping.rs b/shared/src/model/mapping.rs index f49e5c04a..05d4ad699 100644 --- a/shared/src/model/mapping.rs +++ b/shared/src/model/mapping.rs @@ -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 { - 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, diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index 7db08459d..a0795a65c 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -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 { - 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 for XtreamCluster { type Error = String; fn try_from(item_type: PlaylistItemType) -> Result { @@ -82,7 +65,7 @@ impl TryFrom 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 { diff --git a/shared/src/model/processing_order.rs b/shared/src/model/processing_order.rs index 6c04bf3e0..ceee71e9a 100644 --- a/shared/src/model/processing_order.rs +++ b/shared/src/model/processing_order.rs @@ -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, } diff --git a/shared/src/model/stream_properties.rs b/shared/src/model/stream_properties.rs index fa6de8007..72c61c17b 100644 --- a/shared/src/model/stream_properties.rs +++ b/shared/src/model/stream_properties.rs @@ -317,6 +317,8 @@ pub struct EpisodeStreamProperties { pub release_date: Option>, #[serde(default, deserialize_with = "deserialize_as_option_arc_str")] pub series_release_date: Option>, // Global series release date + #[serde(default, deserialize_with = "deserialize_as_option_arc_str")] + pub plot: Option>, #[serde(default, deserialize_with = "deserialize_number_from_string")] pub tmdb: Option, #[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(), diff --git a/shared/src/model/strm_export_style.rs b/shared/src/model/strm_export_style.rs index 461512b96..fde242fec 100644 --- a/shared/src/model/strm_export_style.rs +++ b/shared/src/model/strm_export_style.rs @@ -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 { - match s { - Self::KODI => Ok(Self::Kodi), - Self::EMBY => Ok(Self::Emby), - Self::JELLYFIN => Ok(Self::Jellyfin), - _ => Err(TuliproxError::Config(format!("Unknown StrmExportStyle: {s}"))), - } - } -} diff --git a/shared/src/model/target_type.rs b/shared/src/model/target_type.rs index 772cd7d5c..57e3f38ce 100644 --- a/shared/src/model/target_type.rs +++ b/shared/src/model/target_type.rs @@ -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 { - 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}"))), - } - } -} diff --git a/shared/src/model/view_type.rs b/shared/src/model/view_type.rs index 793af0c24..0d68ea188 100644 --- a/shared/src/model/view_type.rs +++ b/shared/src/model/view_type.rs @@ -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,