From bdd5850cd4c0d888e5094baef7b2505b0761bf1e Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:56:10 +0200 Subject: [PATCH] addded Emby/Jellyfin catalog import to media-server (#927) - Emby/Jellyfin client: token/api_key/login auth, paging, Range playback, artwork - Jellyfin uses only Authorization: MediaBrowser header (Jellyfin 12 compatible) - AnyMediaServerCatalogClient dispatch, shared helpers reused by Plex - Update/Refresh for Emby/Jellyfin inputs in the Web UI - BREAKING: remove unused media_server options (playback, image_policy, catalog.refresh_mode/refresh_on_startup/include_paths/include_user_state) --- CHANGELOG.md | 15 + Cargo.lock | 1 + backend/app/src/api/api_utils/media_server.rs | 79 +- backend/app/src/api/api_utils/tests.rs | 1 + .../api_utils/tests/media_server_stream.rs | 99 + .../stream_responses_response_headers.rs | 8 +- .../tests/behavior_manual_update.rs | 4 +- backend/core/src/model/config/input.rs | 185 +- backend/media-server/Cargo.toml | 1 + backend/media-server/src/any_client.rs | 156 ++ backend/media-server/src/catalog.rs | 73 +- backend/media-server/src/client.rs | 80 +- backend/media-server/src/emby/client.rs | 1707 +++++++++++++++++ backend/media-server/src/emby/dto.rs | 230 ++- backend/media-server/src/emby/flavor.rs | 336 ++++ backend/media-server/src/emby/mapper.rs | 775 ++++++++ backend/media-server/src/emby/mod.rs | 5 + backend/media-server/src/jellyfin/dto.rs | 8 +- backend/media-server/src/jellyfin/flavor.rs | 94 + backend/media-server/src/jellyfin/mod.rs | 3 + backend/media-server/src/lib.rs | 13 +- backend/media-server/src/library_selection.rs | 214 +++ backend/media-server/src/operation.rs | 85 + backend/media-server/src/playback.rs | 82 +- backend/media-server/src/plex/client.rs | 264 ++- backend/media-server/src/plex/mapper.rs | 157 +- backend/media-server/src/redaction.rs | 3 + backend/media-server/src/test_fixtures.rs | 277 +++ backend/media-server/src/test_server.rs | 38 + backend/media-server/src/types.rs | 56 +- .../processor/playlist/ingest/acquisition.rs | 14 +- .../processing/src/processor/playlist/mod.rs | 2 +- backend/processing/src/processor/providers.rs | 48 +- docs/src/configuration/source.md | 259 ++- frontend/public/assets/i18n/ar.json | 1 - frontend/public/assets/i18n/en.json | 1 - frontend/public/assets/i18n/es.json | 1 - frontend/public/assets/i18n/ru.json | 1 - .../components/playlist/input_update_card.rs | 4 +- .../input_update_card_integration_tests.rs | 4 +- .../src/model/input_update_capabilities.rs | 13 +- frontend/src/model/input_update_card_model.rs | 2 +- .../src/model/config/media_server_catalog.rs | 82 - .../tests/catalog_auth.rs | 25 +- shared/src/model/input_update_action.rs | 12 +- 45 files changed, 4844 insertions(+), 674 deletions(-) create mode 100644 backend/app/src/api/api_utils/tests/media_server_stream.rs create mode 100644 backend/media-server/src/any_client.rs create mode 100644 backend/media-server/src/emby/client.rs create mode 100644 backend/media-server/src/emby/flavor.rs create mode 100644 backend/media-server/src/emby/mapper.rs create mode 100644 backend/media-server/src/jellyfin/flavor.rs create mode 100644 backend/media-server/src/library_selection.rs create mode 100644 backend/media-server/src/operation.rs create mode 100644 backend/media-server/src/test_server.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5381e74e9..d2a1c94f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## ⚠️ Breaking Changes +- **Unused `media_server` options removed.** The input keys `media_server.playback` (`direct_play_only`, + `allow_transcode`, `playback_info_policy`, `preflight_streams`), `media_server.image_policy`, + `media_server.catalog.refresh_mode`, `media_server.catalog.refresh_on_startup`, `media_server.catalog.include_paths` + (alias `include_file_paths`) and `media_server.catalog.include_user_state` never had an effect and are no longer + accepted. Configurations that still contain them fail to load with `unknown field`; remove these keys. + - **Web UI sessions must sign in again once.** The new maintenance permissions bump the permission schema, so tokens issued before the update are rejected with `X-Token-Refresh: required` and the Web UI returns to the sign-in page. @@ -186,6 +192,15 @@ ## 🌟 New Features +- **Emby and Jellyfin inputs import their libraries.** `type: emby` and `type: jellyfin` inputs now import the selected + movie and TV libraries like Plex inputs do, and Tuliprox proxies direct playback (with `Range` support) and artwork + without exposing server URLs or tokens to clients. Credentials are resolved from `media_server.token`, then + `media_server.api_key`, then a login with the input's `username` and `password`; `media_server.user_id` selects the + user for non-admin tokens. The login session is reused across refreshes and playback and renewed once when the + server invalidates it. Jellyfin requests use only the `Authorization: MediaBrowser` header, so imports keep working + with Jellyfin 12.0, which removes the legacy `X-Emby-Token` and `api_key` authentication. Missing (virtual) + movies and episodes are not imported; remote or `.strm` sources are imported but not playable. The Update tab + offers Update and Refresh for these inputs. See [Media server inputs](docs/src/configuration/source.md#27-media-server-inputs-plex-emby-jellyfin). - **Maintenance mode.** Inputs can be put into maintenance from the new dashboard card or through `/api/v1/maintenance` (`*` addresses all inputs). New streams of an input in maintenance get the new `maintenance.ts` custom video; playlist and EPG updates keep the last persisted state, and probes, metadata diff --git a/Cargo.lock b/Cargo.lock index 9dbead061..df2cce563 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4731,6 +4731,7 @@ name = "tuliprox-media-server" version = "3.3.156" dependencies = [ "bytes", + "chrono", "futures", "http 1.4.0", "http-body-util", diff --git a/backend/app/src/api/api_utils/media_server.rs b/backend/app/src/api/api_utils/media_server.rs index 13374bdb7..014898982 100644 --- a/backend/app/src/api/api_utils/media_server.rs +++ b/backend/app/src/api/api_utils/media_server.rs @@ -7,7 +7,8 @@ use crate::{ media_server_stream_response as open_media_server_proxy_stream_response, parse_media_server_image_ref, parse_media_server_stream_ref, }, - MediaServerError, MediaServerErrorKind, MediaServerHttpClient, MediaServerImageRef, + AnyMediaServerCatalogClient, MediaServerError, MediaServerErrorKind, MediaServerHttpClient, + MediaServerImageRef, }, model::ConfigInput, }; @@ -16,18 +17,31 @@ use axum::{ http::{header, HeaderMap, Response, StatusCode}, }; use futures::{StreamExt, TryStreamExt}; -use shared::model::InputType; use std::sync::Arc; use url::Url; pub(super) 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) + input.input_type.is_media_server() || is_media_server_stream_ref_url(stream_url) } pub(super) fn is_media_server_stream_ref_url(stream_url: &str) -> bool { Url::parse(stream_url).is_ok_and(|url| url.scheme() == "media-server") } +fn media_server_catalog_client_for_input( + app_state: &AppState, + input: &ConfigInput, + not_media_server_detail: &'static str, +) -> Result { + if !input.input_type.is_media_server() { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) + .provider("media-server") + .detail(not_media_server_detail)); + } + let http_client = MediaServerHttpClient::new(app_state.http_clients.default.load().as_ref().clone()); + input.media_server_catalog_client(http_client) +} + pub(super) async fn open_media_server_stream_for_input( app_state: &Arc, input: &ConfigInput, @@ -36,31 +50,9 @@ pub(super) async fn open_media_server_stream_for_input( ) -> 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_clients.default.load().as_ref().clone()); - - let response = match input.input_type { - InputType::Plex => { - let client = input.plex_catalog_client(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::Stalker - | InputType::StalkerBatch - | InputType::Library - | InputType::Staged => { - return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) - .provider("media-server") - .detail("playlist item is not backed by a media-server input")); - } - }; + let client = + media_server_catalog_client_for_input(app_state, input, "playlist item is not backed by a media-server input")?; + let response = open_media_server_proxy_stream_response(&client, &stream_ref, range).await?; let headers = response .headers @@ -84,31 +76,12 @@ pub(super) async fn open_media_server_image_resource( .provider("media-server") .detail("media-server image input was not found") })?; - let http_client = MediaServerHttpClient::new(app_state.http_clients.default.load().as_ref().clone()); - - let response = match input.input_type { - InputType::Plex => { - let client = input.plex_catalog_client(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::Stalker - | InputType::StalkerBatch - | InputType::Library - | InputType::Staged => { - return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) - .provider("media-server") - .detail("media-server image input is not backed by a media-server input")); - } - }; + let client = media_server_catalog_client_for_input( + app_state, + &input, + "media-server image input is not backed by a media-server input", + )?; + let response = open_media_server_proxy_image_response(&client, &image_ref).await?; let mut builder = Response::builder().status(response.status); for (key, value) in &response.headers { diff --git a/backend/app/src/api/api_utils/tests.rs b/backend/app/src/api/api_utils/tests.rs index 77a58cc05..78cbdc84c 100644 --- a/backend/app/src/api/api_utils/tests.rs +++ b/backend/app/src/api/api_utils/tests.rs @@ -8,6 +8,7 @@ mod http_smoke; mod http_soak; mod local_files; mod maintenance; +mod media_server_stream; mod network_access; mod provider_leases; mod provider_urls; diff --git a/backend/app/src/api/api_utils/tests/media_server_stream.rs b/backend/app/src/api/api_utils/tests/media_server_stream.rs new file mode 100644 index 000000000..2a034bbaf --- /dev/null +++ b/backend/app/src/api/api_utils/tests/media_server_stream.rs @@ -0,0 +1,99 @@ +use super::{create_test_app_state, open_media_server_stream_for_input}; +use crate::model::{ConfigInput, MediaServerInputConfig}; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use futures::StreamExt; +use shared::model::{InputType, MediaServerInputConfigDto, MediaServerLibrarySelector}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; + +const MEDIA_BODY: &[u8] = b"media-bytes"; + +/// Answers the Emby discovery call and the stream call, recording every request head in lowercase. +async fn spawn_emby_test_server() -> (std::net::SocketAddr, tokio::task::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("emby test server binds"); + let addr = listener.local_addr().expect("emby test server address"); + let task = tokio::spawn(async move { + let mut requests = Vec::new(); + for _ in 0..2 { + let Ok((mut socket, _)) = listener.accept().await else { break }; + let mut request = Vec::new(); + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let mut chunk = [0_u8; 1024]; + let Ok(read) = socket.read(&mut chunk).await else { break }; + if read == 0 { + break; + } + request.extend_from_slice(&chunk[..read]); + } + let request = String::from_utf8_lossy(&request).to_ascii_lowercase(); + let response = if request.starts_with("get /system/info/public") { + let body = r#"{"Id":"server-1","ServerName":"Mock"}"#; + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + } else { + format!( + "HTTP/1.1 206 Partial Content\r\nContent-Type: video/mp4\r\nContent-Range: bytes 4-14/100\r\nSet-Cookie: session=secret\r\nX-Emby-Token: upstream-secret\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + MEDIA_BODY.len(), + String::from_utf8_lossy(MEDIA_BODY) + ) + }; + requests.push(request); + socket.write_all(response.as_bytes()).await.expect("emby test server writes response"); + } + requests + }); + (addr, task) +} + +#[tokio::test] +async fn emby_stream_forwards_range_and_strips_credentials_from_the_response() { + let (addr, server) = spawn_emby_test_server().await; + let app_state = create_test_app_state(); + let media_server = MediaServerInputConfig::from(&MediaServerInputConfigDto { + token: Some("emby-secret".to_string()), + libraries: vec![MediaServerLibrarySelector::Name("Movies".to_string())], + ..MediaServerInputConfigDto::default() + }); + let input = ConfigInput { + name: "emby_input".into(), + input_type: InputType::Emby, + url: format!("http://{addr}"), + media_server: Some(media_server), + enabled: true, + ..ConfigInput::default() + }; + let mut request_headers = HeaderMap::new(); + request_headers.insert(header::RANGE, HeaderValue::from_static("bytes=4-14")); + + let (stream, info) = open_media_server_stream_for_input( + &app_state, + &input, + "media-server://emby/server-1/item-1?media_source_id=source-1", + &request_headers, + ) + .await + .expect("emby stream opens"); + + let (headers, status, _, _) = info.expect("response info is present"); + assert_eq!(status, StatusCode::PARTIAL_CONTENT); + assert!(headers.iter().any(|(name, value)| name == "content-range" && value == "bytes 4-14/100"), "{headers:?}"); + for (name, _) in &headers { + assert!( + name != "authorization" && !name.starts_with("x-emby-") && name != "set-cookie", + "credential header {name} leaked into the returned headers" + ); + } + let body: Vec = stream.map(|chunk| chunk.expect("stream chunk").to_vec()).concat().await; + assert_eq!(body, MEDIA_BODY); + + let requests = server.await.expect("emby test server finishes"); + let stream_request = requests.last().expect("stream request is recorded"); + assert!(stream_request.starts_with("get /videos/item-1/stream?"), "{stream_request}"); + assert!(stream_request.contains("mediasourceid=source-1"), "{stream_request}"); + assert!(stream_request.contains("range: bytes=4-14"), "{stream_request}"); + assert!(stream_request.contains("x-emby-token: emby-secret"), "{stream_request}"); +} diff --git a/backend/app/src/api/api_utils/tests/stream_responses_response_headers.rs b/backend/app/src/api/api_utils/tests/stream_responses_response_headers.rs index 26ed21ec4..d5cf0f988 100644 --- a/backend/app/src/api/api_utils/tests/stream_responses_response_headers.rs +++ b/backend/app/src/api/api_utils/tests/stream_responses_response_headers.rs @@ -86,6 +86,7 @@ fn media_server_proxy_response_header_filter_drops_hop_by_hop_headers() { 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 jellyfin_input = ConfigInput { input_type: InputType::Jellyfin, ..ConfigInput::default() }; let m3u_input = ConfigInput { input_type: InputType::M3u, ..ConfigInput::default() }; assert!(is_media_server_playback_url( @@ -97,7 +98,12 @@ fn media_server_playback_urls_are_proxy_only_redirect_guard_candidates() { "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(&emby_input, "https://emby.example/stream.mkv")); + assert!(is_media_server_playback_url(&jellyfin_input, "https://jellyfin.example/stream.mkv")); + assert!(is_media_server_playback_url(&emby_input, "media-server://emby/server/item?source_id=redacted")); + assert!(is_media_server_playback_url(&jellyfin_input, "media-server://jellyfin/server/item?source_id=redacted")); + assert!(is_media_server_stream_ref_url("media-server://emby/server/item?source_id=redacted")); + assert!(is_media_server_stream_ref_url("media-server://jellyfin/server/item?source_id=redacted")); 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")); diff --git a/backend/app/src/api/endpoints/v1_api_playlist/tests/behavior_manual_update.rs b/backend/app/src/api/endpoints/v1_api_playlist/tests/behavior_manual_update.rs index 57f7bf774..94594dba1 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist/tests/behavior_manual_update.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist/tests/behavior_manual_update.rs @@ -32,8 +32,8 @@ fn manual_update_capabilities_reject_unsupported_actions_and_keep_all_required_i (InputType::M3u, 3), (InputType::Plex, 2), (InputType::Library, 1), - (InputType::Jellyfin, 0), - (InputType::Emby, 0), + (InputType::Jellyfin, 2), + (InputType::Emby, 2), (InputType::M3uBatch, 0), (InputType::XtreamBatch, 0), (InputType::StalkerBatch, 0), diff --git a/backend/core/src/model/config/input.rs b/backend/core/src/model/config/input.rs index 39515cba6..08ed20842 100644 --- a/backend/core/src/model/config/input.rs +++ b/backend/core/src/model/config/input.rs @@ -10,10 +10,9 @@ use shared::{ foundation::Filter, model::{ ClusterFlags, ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, ConfigInputStagedDto, - FlussonicHlsCatchup, InputFetchMethod, InputType, MediaServerCatalogConfigDto, MediaServerImagePolicy, - MediaServerInputConfigDto, MediaServerLibrarySelector, MediaServerPlaybackConfigDto, ProviderAccountIdentity, - StagedInputType, StalkerAuthMode, StalkerDeviceProfileDto, StalkerEndpointPreference, StalkerInputConfigDto, - StalkerMagPreset, + FlussonicHlsCatchup, InputFetchMethod, InputType, MediaServerCatalogConfigDto, MediaServerInputConfigDto, + MediaServerLibrarySelector, ProviderAccountIdentity, StagedInputType, StalkerAuthMode, StalkerDeviceProfileDto, + StalkerEndpointPreference, StalkerInputConfigDto, StalkerMagPreset, }, utils::{ get_credentials_from_url, get_credentials_from_url_str, is_non_blank_optional_string, @@ -30,9 +29,10 @@ use std::{ sync::{Arc, LazyLock}, }; use tuliprox_media_server::{ - client::MediaServerHttpClient, - errors::{MediaServerError, MediaServerErrorKind}, + emby::{client::EmbyClientSettings, EmbyCatalogClient}, + jellyfin::JellyfinCatalogClient, plex::client::{PlexCatalogClient, PlexClientSettings}, + AnyMediaServerCatalogClient, MediaServerError, MediaServerErrorKind, MediaServerHttpClient, }; use url::Url; @@ -128,8 +128,6 @@ static DEFAULT_CONFIG_INPUT_OPTIONS: LazyLock = pub struct MediaServerInputConfig { pub libraries: Vec, pub catalog: MediaServerCatalogConfigDto, - pub playback: MediaServerPlaybackConfigDto, - pub image_policy: MediaServerImagePolicy, pub token: Option, pub api_key: Option, pub user_id: Option, @@ -159,22 +157,64 @@ impl MediaServerInputConfig { } impl ConfigInput { - /// Builds the Plex catalog client this input describes. - /// - /// Previously `PlexCatalogClient::from_input`; it moved out of `media_server` - /// because it is the only thing there that knew what a `ConfigInput` is. - pub fn plex_catalog_client(&self, http: MediaServerHttpClient) -> Result { - if self.input_type != InputType::Plex { - return Err(MediaServerError::new(MediaServerErrorKind::MediaServerDiscoveryFailed) - .provider("plex") - .detail("plex catalog client requires a plex input")); - } - let media_server = self.media_server.as_ref().ok_or_else(|| { + /// The `media_server` block of this input, or a discovery error naming `provider`. + fn media_server_config(&self, provider: &'static str) -> Result<&MediaServerInputConfig, MediaServerError> { + self.media_server.as_ref().ok_or_else(|| { MediaServerError::new(MediaServerErrorKind::MediaServerDiscoveryFailed) - .provider("plex") - .detail("plex input is missing media_server configuration") - })?; - Ok(PlexCatalogClient::new(self.name.clone(), self.url.as_str(), &media_server.plex_client_settings(), http)) + .provider(provider) + .detail("media-server input is missing media_server configuration") + }) + } + + fn plex_client(&self, media_server: &MediaServerInputConfig, http: MediaServerHttpClient) -> PlexCatalogClient { + PlexCatalogClient::new(self.name.clone(), self.url.as_str(), &media_server.plex_client_settings(), http) + } + + /// The subset of this input an Emby/Jellyfin catalog client actually needs: server credentials from + /// the `media_server` block, login credentials from the input's own `username`/`password`. + pub fn emby_client_settings(&self, media_server: &MediaServerInputConfig) -> EmbyClientSettings { + EmbyClientSettings { + token: media_server.token.clone(), + api_key: media_server.api_key.clone(), + user_id: media_server.user_id.clone(), + username: self.username.clone(), + password: self.password.clone(), + include_media_sources: media_server.catalog.include_media_sources, + libraries: media_server.libraries.clone(), + } + } + + /// Builds the catalog client for whichever media-server kind this input is. + pub fn media_server_catalog_client( + &self, + http: MediaServerHttpClient, + ) -> Result { + match self.input_type { + InputType::Plex => { + let media_server = self.media_server_config("plex")?; + Ok(AnyMediaServerCatalogClient::Plex(self.plex_client(media_server, http))) + } + InputType::Emby => { + let settings = self.emby_client_settings(self.media_server_config("emby")?); + Ok(AnyMediaServerCatalogClient::Emby(EmbyCatalogClient::new( + self.name.clone(), + self.url.as_str(), + &settings, + http, + )?)) + } + InputType::Jellyfin => { + let settings = self.emby_client_settings(self.media_server_config("jellyfin")?); + Ok(AnyMediaServerCatalogClient::Jellyfin(JellyfinCatalogClient::new( + self.name.clone(), + self.url.as_str(), + &settings, + http, + )?)) + } + _ => Err(MediaServerError::new(MediaServerErrorKind::MediaServerDiscoveryFailed) + .detail("input is not a media-server input")), + } } } @@ -195,8 +235,6 @@ impl From<&MediaServerInputConfigDto> for MediaServerInputConfig { Self { libraries: normalized.libraries, catalog: normalized.catalog, - playback: normalized.playback, - image_policy: normalized.image_policy, token: normalized.token, api_key: normalized.api_key, user_id: normalized.user_id, @@ -1129,6 +1167,101 @@ mod tests { }) } + fn media_server_input( + input_type: InputType, + url: &str, + media_server: Option, + ) -> ConfigInput { + ConfigInput { + name: "ms".into(), + input_type, + url: url.to_string(), + media_server, + enabled: true, + ..Default::default() + } + } + + #[test] + fn media_server_catalog_client_builds_each_kind() { + let http = || MediaServerHttpClient::new(reqwest::Client::new()); + let with = |token: Option<&str>, api_key: Option<&str>| { + Some(MediaServerInputConfig { + token: token.map(str::to_string), + api_key: api_key.map(str::to_string), + ..media_server_config_with_library() + }) + }; + + let plex = media_server_input(InputType::Plex, "http://plex.example.invalid:32400", with(Some("t"), None)); + assert!(matches!(plex.media_server_catalog_client(http()), Ok(AnyMediaServerCatalogClient::Plex(_)))); + + let emby = media_server_input(InputType::Emby, "http://emby.example.invalid:8096", with(Some("t"), None)); + assert!(matches!(emby.media_server_catalog_client(http()), Ok(AnyMediaServerCatalogClient::Emby(_)))); + + let jellyfin = + media_server_input(InputType::Jellyfin, "http://jf.example.invalid:8096", with(None, Some("key"))); + assert!(matches!(jellyfin.media_server_catalog_client(http()), Ok(AnyMediaServerCatalogClient::Jellyfin(_)))); + } + + #[test] + fn media_server_catalog_client_rejects_non_media_server_input() { + let input = media_server_input(InputType::M3u, "http://example.invalid/list.m3u", None); + + let err = input + .media_server_catalog_client(MediaServerHttpClient::new(reqwest::Client::new())) + .err() + .expect("m3u input must be rejected"); + + assert_eq!(err.kind, MediaServerErrorKind::MediaServerDiscoveryFailed); + assert!(err.to_string().contains("input is not a media-server input"), "{err}"); + } + + #[test] + fn media_server_catalog_client_requires_media_server_block() { + for (input_type, provider) in + [(InputType::Plex, "plex"), (InputType::Emby, "emby"), (InputType::Jellyfin, "jellyfin")] + { + let input = media_server_input(input_type, "http://ms.example.invalid", None); + + let err = input + .media_server_catalog_client(MediaServerHttpClient::new(reqwest::Client::new())) + .err() + .expect("missing media_server block must be rejected"); + + assert_eq!(err.kind, MediaServerErrorKind::MediaServerDiscoveryFailed); + assert_eq!(err.provider, Some(provider)); + assert!(err.to_string().contains("media-server input is missing media_server configuration"), "{err}"); + } + } + + #[test] + fn emby_client_settings_copies_login_credentials_and_media_source_flag() { + let media_server = MediaServerInputConfig { + token: Some("token".to_string()), + api_key: Some("api-key".to_string()), + user_id: Some("user-id".to_string()), + ..media_server_config_with_library() + }; + let mut input = media_server_input(InputType::Emby, "http://emby.example.invalid", Some(media_server.clone())); + input.username = Some("login".to_string()); + input.password = Some("secret".to_string()); + + let settings = input.emby_client_settings(&media_server); + + assert_eq!(settings.token.as_deref(), Some("token")); + assert_eq!(settings.api_key.as_deref(), Some("api-key")); + assert_eq!(settings.user_id.as_deref(), Some("user-id")); + assert_eq!(settings.username.as_deref(), Some("login")); + assert_eq!(settings.password.as_deref(), Some("secret")); + assert_eq!(settings.libraries, media_server.libraries); + assert!(settings.include_media_sources); + + let mut no_sources = media_server; + no_sources.catalog.include_media_sources = false; + assert!(!input.emby_client_settings(&no_sources).include_media_sources); + } + #[test] fn input_options_conversion_sets_disable_hls_streaming_flag() { let dto = ConfigInputOptionsDto { @@ -1262,8 +1395,6 @@ mod tests { 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); assert_eq!(media_server.token.as_deref(), Some("token")); assert_eq!(media_server.api_key.as_deref(), Some("api-key")); assert_eq!(media_server.user_id.as_deref(), Some("user")); diff --git a/backend/media-server/Cargo.toml b/backend/media-server/Cargo.toml index 8789f762d..edb6fec83 100644 --- a/backend/media-server/Cargo.toml +++ b/backend/media-server/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] bytes.workspace = true +chrono.workspace = true futures.workspace = true http = "1" quick-xml = { version = "0.41", features = ["async-tokio", "serialize"] } diff --git a/backend/media-server/src/any_client.rs b/backend/media-server/src/any_client.rs new file mode 100644 index 000000000..35cba7c8b --- /dev/null +++ b/backend/media-server/src/any_client.rs @@ -0,0 +1,156 @@ +use crate::{ + emby::EmbyCatalogClient, jellyfin::JellyfinCatalogClient, plex::client::PlexCatalogClient, + MediaServerCatalogClient, MediaServerEpisode, MediaServerError, MediaServerImageRef, MediaServerLibrary, + MediaServerLibraryRef, MediaServerMovie, MediaServerPage, MediaServerPageRequest, MediaServerSeason, + MediaServerSeries, MediaServerStatus, MediaServerStreamRef, MediaServerStreamResponse, +}; + +/// Statically dispatched catalog client for any supported media-server kind. +/// +/// `MediaServerCatalogClient` is not object safe, so callers that only learn the +/// kind from configuration hold this enum instead of a trait object. +pub enum AnyMediaServerCatalogClient { + Plex(PlexCatalogClient), + Emby(EmbyCatalogClient), + Jellyfin(JellyfinCatalogClient), +} + +/// Forwards one trait method call to whichever client the enum holds. +macro_rules! dispatch { + ($client:expr, $method:ident($($arg:expr),*)) => { + match $client { + AnyMediaServerCatalogClient::Plex(client) => client.$method($($arg),*).await, + AnyMediaServerCatalogClient::Emby(client) => client.$method($($arg),*).await, + AnyMediaServerCatalogClient::Jellyfin(client) => client.$method($($arg),*).await, + } + }; +} + +impl MediaServerCatalogClient for AnyMediaServerCatalogClient { + async fn discover(&self) -> Result { dispatch!(self, discover()) } + + async fn list_libraries(&self) -> Result, MediaServerError> { + dispatch!(self, list_libraries()) + } + + async fn list_movies( + &self, + library: &MediaServerLibraryRef, + page: MediaServerPageRequest, + ) -> Result, MediaServerError> { + dispatch!(self, list_movies(library, page)) + } + + async fn list_series( + &self, + library: &MediaServerLibraryRef, + page: MediaServerPageRequest, + ) -> Result, MediaServerError> { + dispatch!(self, list_series(library, page)) + } + + async fn list_seasons( + &self, + library: &MediaServerLibraryRef, + page: MediaServerPageRequest, + ) -> Result, MediaServerError> { + dispatch!(self, list_seasons(library, page)) + } + + async fn list_episodes( + &self, + library: &MediaServerLibraryRef, + page: MediaServerPageRequest, + ) -> Result, MediaServerError> { + dispatch!(self, list_episodes(library, page)) + } + + async fn open_stream( + &self, + stream_ref: &MediaServerStreamRef, + range: Option<&str>, + ) -> Result { + dispatch!(self, open_stream(stream_ref, range)) + } + + async fn open_image(&self, image_ref: &MediaServerImageRef) -> Result { + dispatch!(self, open_image(image_ref)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + emby::client::EmbyClientSettings, plex::client::PlexClientSettings, MediaServerErrorKind, MediaServerHttpClient, + }; + use std::sync::Arc; + + const INPUT: &str = "any-client-input"; + const CLOSED_PORT_URL: &str = "http://127.0.0.1:1"; + + fn http() -> MediaServerHttpClient { MediaServerHttpClient::new(reqwest::Client::new()) } + + fn emby_settings() -> EmbyClientSettings { + EmbyClientSettings { token: Some("secret".to_string()), ..EmbyClientSettings::default() } + } + + fn plex_settings() -> PlexClientSettings { + PlexClientSettings { token: Some("secret".to_string()), ..PlexClientSettings::default() } + } + + fn all_clients(url: &str) -> Vec<(&'static str, AnyMediaServerCatalogClient)> { + let name: Arc = Arc::from(INPUT); + vec![ + ( + "plex", + AnyMediaServerCatalogClient::Plex(PlexCatalogClient::new(name.clone(), url, &plex_settings(), http())), + ), + ( + "emby", + AnyMediaServerCatalogClient::Emby( + EmbyCatalogClient::new(name.clone(), url, &emby_settings(), http()).expect("emby client"), + ), + ), + ( + "jellyfin", + AnyMediaServerCatalogClient::Jellyfin( + JellyfinCatalogClient::new(name, url, &emby_settings(), http()).expect("jellyfin client"), + ), + ), + ] + } + + fn foreign_stream_ref(provider: &str) -> MediaServerStreamRef { + let (input_name, server_id, item_id): (Arc, Arc, Arc) = + (Arc::from(INPUT), Arc::from("server"), Arc::from("item")); + // Each client is handed a ref of the next kind in the Plex -> Emby -> Jellyfin cycle. + match provider { + "plex" => MediaServerStreamRef::Emby { input_name, server_id, item_id, media_source_id: None }, + "emby" => MediaServerStreamRef::Jellyfin { input_name, server_id, item_id, media_source_id: None }, + _ => { + MediaServerStreamRef::Plex { input_name, server_id, rating_key: item_id, part_key: Arc::from("/part") } + } + } + } + + #[tokio::test] + async fn open_stream_routes_to_each_variant_and_rejects_foreign_refs() { + for (provider, client) in all_clients(CLOSED_PORT_URL) { + let err = client + .open_stream(&foreign_stream_ref(provider), None) + .await + .err() + .unwrap_or_else(|| panic!("{provider} accepted a foreign stream ref")); + + assert_eq!(err.kind, MediaServerErrorKind::MediaServerStreamOpenFailed, "{provider}"); + } + } + + #[tokio::test] + async fn discover_routes_to_each_variant_and_fails_without_panicking_on_closed_port() { + for (provider, client) in all_clients(CLOSED_PORT_URL) { + assert!(client.discover().await.is_err(), "{provider} discovery must fail on a closed port"); + } + } +} diff --git a/backend/media-server/src/catalog.rs b/backend/media-server/src/catalog.rs index 4504f3abb..fdc0393aa 100644 --- a/backend/media-server/src/catalog.rs +++ b/backend/media-server/src/catalog.rs @@ -58,42 +58,6 @@ impl MediaServerCatalogSnapshot { } } -#[derive(Debug, Clone, Default)] -pub struct MediaServerCatalogCache { - trusted: Option, -} - -impl MediaServerCatalogCache { - pub fn trusted(&self) -> Option<&MediaServerCatalogSnapshot> { self.trusted.as_ref() } - - pub fn publish(&mut self, snapshot: MediaServerCatalogSnapshot) -> &MediaServerCatalogSnapshot { - self.trusted.insert(snapshot) - } - - pub async fn refresh_or_retain( - &mut self, - client: &C, - policy: MediaServerCatalogRefreshPolicy, - ) -> MediaServerCatalogRefreshOutcome - where - C: MediaServerCatalogClient, - { - match refresh_media_server_catalog_complete_before_publish(client, policy).await { - Ok(snapshot) => { - self.publish(snapshot); - MediaServerCatalogRefreshOutcome::Published - } - Err(error) => MediaServerCatalogRefreshOutcome::Retained { error, retained: self.trusted.is_some() }, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum MediaServerCatalogRefreshOutcome { - Published, - Retained { error: MediaServerError, retained: bool }, -} - pub async fn refresh_media_server_catalog_complete_before_publish( client: &C, policy: MediaServerCatalogRefreshPolicy, @@ -188,8 +152,8 @@ mod tests { use super::*; use crate::{ MediaServerDescriptiveFacts, MediaServerImageRef, MediaServerKind, MediaServerLibraryRef, - MediaServerProviderIdHint, MediaServerResourceResponse, MediaServerSeason, MediaServerSeries, - MediaServerStatus, MediaServerStreamRef, MediaServerStreamResponse, + MediaServerProviderIdHint, MediaServerSeason, MediaServerSeries, MediaServerStatus, MediaServerStreamRef, + MediaServerStreamResponse, }; use bytes::Bytes; use futures::{stream, StreamExt}; @@ -274,15 +238,11 @@ mod tests { fn open_image( &self, _image_ref: &MediaServerImageRef, - ) -> impl std::future::Future> { - std::future::ready(Ok(empty_response())) + ) -> impl std::future::Future> { + std::future::ready(Ok(empty_stream_response())) } } - fn empty_response() -> MediaServerResourceResponse { - MediaServerResourceResponse { status: StatusCode::OK, headers: HeaderMap::new(), body: Bytes::new() } - } - fn empty_stream_response() -> MediaServerStreamResponse { MediaServerStreamResponse { status: StatusCode::OK, @@ -403,31 +363,6 @@ mod tests { } } - #[tokio::test] - async fn incomplete_refresh_retains_previous_trusted_snapshot() { - let mut cache = MediaServerCatalogCache::default(); - cache.publish(MediaServerCatalogSnapshot { - movies: vec![movie("old")], - ..MediaServerCatalogSnapshot::default() - }); - - 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("new-1")])), - Err(MediaServerError::new(MediaServerErrorKind::MediaServerUnavailable)), - ]); - - let outcome = cache - .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()]); diff --git a/backend/media-server/src/client.rs b/backend/media-server/src/client.rs index 0b7fc86fa..f19ee9186 100644 --- a/backend/media-server/src/client.rs +++ b/backend/media-server/src/client.rs @@ -1,7 +1,7 @@ use crate::{ - redaction::redact_media_server_text, MediaServerEpisode, MediaServerError, MediaServerErrorKind, - MediaServerImageRef, MediaServerLibrary, MediaServerLibraryRef, MediaServerMovie, MediaServerPage, - MediaServerPageRequest, MediaServerResourceResponse, MediaServerSeason, MediaServerSeries, MediaServerStatus, + operation::MediaServerOperation, redaction::redact_media_server_text, MediaServerEpisode, MediaServerError, + MediaServerErrorKind, MediaServerImageRef, MediaServerLibrary, MediaServerLibraryRef, MediaServerMovie, + MediaServerPage, MediaServerPageRequest, MediaServerSeason, MediaServerSeries, MediaServerStatus, MediaServerStreamRef, MediaServerStreamResponse, }; use reqwest::{ @@ -45,10 +45,7 @@ pub trait MediaServerCatalogClient: Send + Sync { range: Option<&str>, ) -> Result; - async fn open_image( - &self, - image_ref: &MediaServerImageRef, - ) -> Result; + async fn open_image(&self, image_ref: &MediaServerImageRef) -> Result; } #[derive(Clone)] @@ -69,6 +66,15 @@ impl MediaServerHttpClient { fallback_kind: MediaServerErrorKind::MediaServerStreamOpenFailed, } } + + pub(crate) fn request_for( + &self, + method: Method, + url: &str, + operation: MediaServerOperation, + ) -> MediaServerHttpRequestBuilder { + self.request(method, url).error_kinds(operation.not_found_kind(), operation.fallback_kind()) + } } pub struct MediaServerHttpRequestBuilder { @@ -91,6 +97,11 @@ impl MediaServerHttpRequestBuilder { self } + pub fn json(mut self, body: &T) -> Self { + self.builder = self.builder.json(body); + self + } + pub fn error_kinds(mut self, not_found_kind: MediaServerErrorKind, fallback_kind: MediaServerErrorKind) -> Self { self.not_found_kind = not_found_kind; self.fallback_kind = fallback_kind; @@ -153,4 +164,59 @@ mod tests { assert_eq!(request.not_found_kind, MediaServerErrorKind::MediaServerLibraryUnavailable); assert_eq!(request.fallback_kind, MediaServerErrorKind::MediaServerCatalogDecodeFailed); } + + #[test] + fn request_for_selects_error_kinds_per_operation() { + let client = MediaServerHttpClient::new(reqwest::Client::new()); + for operation in + [MediaServerOperation::Discovery, MediaServerOperation::Catalog, MediaServerOperation::Playback] + { + let request = client.request_for(Method::GET, "https://media.example.invalid/x", operation); + + assert_eq!(request.not_found_kind, operation.not_found_kind()); + assert_eq!(request.fallback_kind, operation.fallback_kind()); + } + } + + #[test] + fn json_keeps_safe_url_redacted() { + let client = MediaServerHttpClient::new(reqwest::Client::new()); + let request = client + .request(Method::POST, "https://media.example.invalid/login?api_key=secret") + .json(&serde_json::json!({"Username": "u"})); + + assert!(!request.safe_url().contains("secret")); + assert!(request.safe_url().contains("api_key=***")); + } + + #[tokio::test] + async fn json_sends_serialized_body_with_content_type() { + use crate::test_server::{start_test_server, test_response}; + use http_body_util::BodyExt; + use reqwest::StatusCode; + + let (base_url, server) = start_test_server(|req| async move { + let content_type = req + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned); + let body = req.into_body().collect().await.map(http_body_util::Collected::to_bytes).unwrap_or_default(); + let ok = + content_type.as_deref() == Some("application/json") && body.as_ref() == br#"{"Username":"u","Pw":"p"}"#; + Ok(test_response(if ok { StatusCode::OK } else { StatusCode::BAD_REQUEST }, b"")) + }) + .await; + let client = MediaServerHttpClient::new(reqwest::Client::new()); + + let response = client + .request_for(Method::POST, &format!("{base_url}/login"), MediaServerOperation::Discovery) + .json(&serde_json::json!({"Username": "u", "Pw": "p"})) + .send() + .await + .expect("request is sent"); + + assert_eq!(response.status(), StatusCode::OK); + server.abort(); + } } diff --git a/backend/media-server/src/emby/client.rs b/backend/media-server/src/emby/client.rs new file mode 100644 index 000000000..ed9dfaeba --- /dev/null +++ b/backend/media-server/src/emby/client.rs @@ -0,0 +1,1707 @@ +use crate::{ + emby::{ + dto::{ + EmbyAuthenticateByNameRequest, EmbyAuthenticationResultDto, EmbyItemDto, EmbyItemsPageDto, + EmbyPublicSystemInfoDto, EmbyViewDto, EmbyVirtualFolderDto, + }, + flavor::{validate_token, EmbyClientIdentity, EmbyFamilyFlavor, UserScope}, + mapper::{ + emby_item_to_episode, emby_item_to_movie, emby_item_to_season, emby_item_to_series, is_virtual_item, + EmbyMapContext, + }, + }, + library_selection::select_libraries, + non_blank_arc, + operation::MediaServerOperation, + redaction::redacted_secret, + MediaServerCatalogClient, MediaServerEpisode, MediaServerError, MediaServerErrorKind, MediaServerHttpClient, + MediaServerImageRef, MediaServerLibrary, MediaServerLibraryRef, MediaServerMovie, MediaServerPage, + MediaServerPageRequest, MediaServerSeason, MediaServerSeries, MediaServerStatus, MediaServerStreamRef, + MediaServerStreamResponse, +}; +use futures::{StreamExt, TryStreamExt}; +use reqwest::{ + header::{HeaderValue, RANGE}, + Method, +}; +use serde::de::DeserializeOwned; +use shared::{model::MediaServerLibrarySelector, utils::hash_string_as_hex}; +use std::{ + collections::HashMap, + marker::PhantomData, + sync::{Arc, LazyLock}, +}; +use tokio::sync::Mutex; +use url::Url; + +/// Fields requested for every item; `MediaSources` is appended for playable items when configured. +macro_rules! item_fields { + () => { + "ProviderIds,Overview,Genres,Studios,People,OfficialRating,CommunityRating,PremiereDate,DateCreated,\ + OriginalTitle,SortName,Taglines,ProductionLocations,ChildCount,RecursiveItemCount" + }; +} +/// Image kinds an item reference may ask for; anything else never reaches the server. +const IMAGE_KINDS: [&str; 5] = ["Primary", "Backdrop", "Thumb", "Logo", "Banner"]; +const ITEM_FIELDS: &str = item_fields!(); +const ITEM_FIELDS_WITH_MEDIA_SOURCES: &str = concat!(item_fields!(), ",MediaSources"); + +/// Session shared by every client with the same cache key. Its lock is the +/// single-flight guard for logging in, scoped to one key so a slow server only +/// delays the clients of its own input. +type SharedConnection = Arc>>; + +/// Only ever locked to look up or insert a cell, never across `.await`. +static EMBY_CONNECTION_CACHE: LazyLock>> = + LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + +fn shared_connection(key: &EmbyConnectionCacheKey) -> SharedConnection { + let mut cache = EMBY_CONNECTION_CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + prune_idle_empty_connections(&mut cache); + Arc::clone(cache.entry(key.clone()).or_default()) +} + +/// Failed connects and invalidated sessions leave empty cells behind. A cell no +/// client holds anymore and that carries no session is dropped; cells in use stay, +/// so removing one can never split a running single-flight login into two. +fn prune_idle_empty_connections(cache: &mut HashMap) { + cache.retain(|_, cell| Arc::strong_count(cell) > 1 || cell.try_lock().map_or(true, |state| state.is_some())); +} + +/// Everything an Emby-family catalog client needs in order to connect. +/// +/// This is the boundary type: the caller resolves its own configuration and hands +/// over the resolved values, so this crate never names the application's +/// configuration model. +#[derive(Clone, Default)] +pub struct EmbyClientSettings { + pub token: Option, + pub api_key: Option, + pub user_id: Option, + pub username: Option, + pub password: Option, + pub include_media_sources: bool, + pub libraries: Vec, +} + +impl std::fmt::Debug for EmbyClientSettings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EmbyClientSettings") + .field("token", &redacted_secret(self.token.as_deref())) + .field("api_key", &redacted_secret(self.api_key.as_deref())) + .field("user_id", &self.user_id) + .field("username", &self.username) + .field("password", &redacted_secret(self.password.as_deref())) + .field("include_media_sources", &self.include_media_sources) + .field("libraries", &self.libraries) + .finish() + } +} + +struct EmbyClientConfig { + input_name: Arc, + base_url: Url, + token: Option>, + api_key: Option>, + user_id: Option>, + username: Option>, + password: Option>, + include_media_sources: bool, + libraries: Vec, +} + +/// Where the token came from decides whether a `401` can be healed by logging in again. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum TokenOrigin { + Configured, + Login, +} + +#[derive(Debug, Clone)] +struct EmbyConnectionState { + status: MediaServerStatus, + token: Arc, + user_id: Option>, + origin: TokenOrigin, +} + +/// Secrets are reduced to fingerprints so the process-wide cache holds no extra plaintext copies. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct EmbyConnectionCacheKey { + kind: crate::MediaServerKind, + input_name: Arc, + base_url: Arc, + token: Option>, + api_key: Option>, + user_id: Option>, + username: Option>, + password: Option>, +} + +impl EmbyConnectionCacheKey { + fn new(kind: crate::MediaServerKind, config: &EmbyClientConfig) -> Self { + Self { + kind, + input_name: Arc::clone(&config.input_name), + base_url: Arc::from(config.base_url.as_str()), + token: fingerprint(config.token.as_ref()), + api_key: fingerprint(config.api_key.as_ref()), + user_id: config.user_id.clone(), + username: config.username.clone(), + password: fingerprint(config.password.as_ref()), + } + } +} + +fn fingerprint(secret: Option<&Arc>) -> Option> { + secret.map(|secret| Arc::::from(hash_string_as_hex(secret))) +} + +pub struct EmbyFamilyCatalogClient { + http: MediaServerHttpClient, + config: EmbyClientConfig, + identity: EmbyClientIdentity, + shared: SharedConnection, + connection: Mutex>, + _flavor: PhantomData F>, +} + +impl EmbyFamilyCatalogClient { + pub fn new( + input_name: Arc, + base_url: &str, + settings: &EmbyClientSettings, + http: MediaServerHttpClient, + ) -> Result { + let base_url = parse_base_url(base_url).ok_or_else(|| { + MediaServerError::new(MediaServerErrorKind::MediaServerDiscoveryFailed) + .provider(F::PROVIDER) + .detail("server URL is missing or not a valid http(s) URL") + })?; + let username = non_blank_arc(settings.username.as_deref()); + let identity = EmbyClientIdentity::new(&input_name, username.as_deref()); + let config = EmbyClientConfig { + input_name, + base_url, + token: non_blank_arc(settings.token.as_deref()), + api_key: non_blank_arc(settings.api_key.as_deref()), + user_id: non_blank_arc(settings.user_id.as_deref()), + username, + // Passwords are significant byte for byte, only an empty one counts as unset. + password: settings.password.as_deref().filter(|password| !password.is_empty()).map(Arc::::from), + include_media_sources: settings.include_media_sources, + libraries: settings.libraries.clone(), + }; + let shared = shared_connection(&EmbyConnectionCacheKey::new(F::KIND, &config)); + Ok(Self { http, config, identity, shared, connection: Mutex::new(None), _flavor: PhantomData }) + } + + /// Cached session, or a fresh one. Connecting happens under the per-key lock: + /// Jellyfin keeps a single token per `DeviceId`, so two concurrent logins + /// would invalidate each other. The lock is never held during catalog or + /// stream requests. + async fn connection(&self) -> Result { + if let Some(connection) = self.connection.lock().await.clone() { + return Ok(connection); + } + + let connection = { + let mut shared = self.shared.lock().await; + if let Some(connection) = shared.as_ref() { + connection.clone() + } else { + let connection = self.establish().await?; + *shared = Some(connection.clone()); + connection + } + }; + *self.connection.lock().await = Some(connection.clone()); + Ok(connection) + } + + /// Drops the cached session, but only while it still holds `failed_token`, so a + /// client that already re-logged in is not thrown away by a slower sibling. + async fn invalidate(&self, failed_token: &str) { + { + let mut shared = self.shared.lock().await; + if shared.as_ref().is_some_and(|connection| connection.token.as_ref() == failed_token) { + *shared = None; + } + } + let mut local = self.connection.lock().await; + if local.as_ref().is_some_and(|connection| connection.token.as_ref() == failed_token) { + *local = None; + } + } + + async fn establish(&self) -> Result { + let info: EmbyPublicSystemInfoDto = self.discover_public_info().await?; + + // Token, then API key, then login with the input's username and password. + let (token, origin, mut user_id, login_server_id) = + if let Some(token) = self.config.token.as_ref().or(self.config.api_key.as_ref()) { + validate_token(token, F::PROVIDER)?; + (Arc::clone(token), TokenOrigin::Configured, None, None) + } else if let Some(username) = self.config.username.as_deref() { + let password = self.config.password.as_deref().unwrap_or_default(); + let login = self.login(username, password).await?; + let token = non_blank_arc(login.access_token.as_deref()).ok_or_else(|| { + MediaServerError::new(MediaServerErrorKind::MediaServerAuthDenied) + .provider(F::PROVIDER) + .detail("login did not return an access token") + })?; + let user_id = login.user.as_ref().and_then(|user| non_blank_arc(user.id.as_deref())); + (token, TokenOrigin::Login, user_id, non_blank_arc(login.server_id.as_deref())) + } else { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerAuthDenied) + .provider(F::PROVIDER) + .detail("no token, api_key or username/password is configured")); + }; + if let Some(configured) = self.config.user_id.as_ref() { + user_id = Some(Arc::clone(configured)); + } + + let server_id = non_blank_arc(info.id.as_deref()).or(login_server_id).ok_or_else(|| { + MediaServerError::new(MediaServerErrorKind::MediaServerDiscoveryFailed) + .provider(F::PROVIDER) + .detail("server did not expose a stable id") + })?; + let status = MediaServerStatus { + kind: F::KIND, + server_id, + display_name: non_blank_arc(info.server_name.as_deref()), + version: non_blank_arc(info.version.as_deref()), + owned: None, + }; + Ok(EmbyConnectionState { status, token, user_id, origin }) + } + + async fn discover_public_info(&self) -> Result { + let url = self.url(&["System", "Info", "Public"], &[])?; + let response = self + .identity_request(Method::GET, &url)? + .send_with_error_detail(format!("{} discovery request failed", F::PROVIDER)) + .await?; + self.read_json(response, MediaServerOperation::Discovery, "discovery").await + } + + async fn login(&self, username: &str, password: &str) -> Result { + let url = self.url(&["Users", "AuthenticateByName"], &[])?; + let response = self + .identity_request(Method::POST, &url)? + .json(&EmbyAuthenticateByNameRequest { username, pw: password }) + .send_with_error_detail(format!("{} login request failed", F::PROVIDER)) + .await?; + self.read_json(response, MediaServerOperation::Discovery, "login").await + } + + /// Request carrying only the client identity: discovery and login are made without a token. + fn identity_request( + &self, + method: Method, + url: &Url, + ) -> Result { + let headers = F::auth_headers(&self.identity, None)?; + Ok(self.http.request_for(method, url.as_str(), MediaServerOperation::Discovery).headers(headers)) + } + + /// Decodes a JSON body after checking the status; `what` names the call in error details. + async fn read_json( + &self, + response: reqwest::Response, + operation: MediaServerOperation, + what: &str, + ) -> Result { + let status = response.status(); + if !status.is_success() { + return Err(operation + .status_error(status, F::PROVIDER) + .detail(format!("{} {what} returned {status}", F::PROVIDER))); + } + response.json::().await.map_err(|err| { + operation.reqwest_error(&err, F::PROVIDER).detail(format!("{} {what} response decode failed", F::PROVIDER)) + }) + } + + fn url(&self, segments: &[&str], query: &[(&str, &str)]) -> Result { + endpoint_url(&self.config.base_url, segments, query).ok_or_else(|| { + MediaServerError::new(MediaServerErrorKind::MediaServerDiscoveryFailed) + .provider(F::PROVIDER) + .detail("server URL cannot carry a request path") + }) + } + + /// Sends an authenticated request. A `401` on a token obtained by logging in + /// (expired, or revoked by a server restart) evicts the session, logs in again + /// and retries once; any other `401` is `MediaServerAuthDenied`. + async fn send_authenticated( + &self, + operation: MediaServerOperation, + method: Method, + url: &Url, + range: Option<&HeaderValue>, + ) -> Result { + let mut connection = self.connection().await?; + let mut relogged = false; + loop { + let mut headers = F::auth_headers(&self.identity, Some(&connection.token))?; + if let Some(range) = range { + headers.insert(RANGE, range.clone()); + } + let response = self + .http + .request_for(method.clone(), url.as_str(), operation) + .headers(headers) + .send_with_error_detail(format!("{} request failed", F::PROVIDER)) + .await?; + let status = response.status(); + if status.is_success() { + return Ok(response); + } + if status == reqwest::StatusCode::UNAUTHORIZED && connection.origin == TokenOrigin::Login && !relogged { + relogged = true; + self.invalidate(&connection.token).await; + connection = self.connection().await?; + continue; + } + return Err(operation + .status_error(status, F::PROVIDER) + .detail(format!("{} request returned {status}", F::PROVIDER))); + } + } + + async fn get_json(&self, url: &Url) -> Result { + let response = self.send_authenticated(MediaServerOperation::Catalog, Method::GET, url, None).await?; + self.read_json(response, MediaServerOperation::Catalog, "catalog").await + } + + async fn fetch_item_page( + &self, + connection: &EmbyConnectionState, + library: &MediaServerLibraryRef, + page: MediaServerPageRequest, + item_type: &'static str, + ) -> Result, MediaServerError> { + let user_id = connection.user_id.as_deref(); + let endpoint = F::items_path(user_id); + let fields = if self.config.include_media_sources && matches!(item_type, "Movie" | "Episode") { + ITEM_FIELDS_WITH_MEDIA_SOURCES + } else { + ITEM_FIELDS + }; + let start = page.start.to_string(); + let limit = page.limit.to_string(); + let mut query = vec![ + ("IncludeItemTypes", item_type), + ("ParentId", library.library_id.as_ref()), + ("Recursive", "true"), + ("Fields", fields), + ("StartIndex", start.as_str()), + ("Limit", limit.as_str()), + ("EnableTotalRecordCount", "true"), + ("SortBy", "SortName"), + ("SortOrder", "Ascending"), + ("CollapseBoxSetItems", "false"), + ]; + if let (UserScope::Query, Some(user_id)) = (endpoint.user_scope, user_id) { + query.push(("UserId", user_id)); + } + let url = self.url(endpoint.path.segments(), &query)?; + self.get_json(&url).await + } + + async fn list_items( + &self, + library: &MediaServerLibraryRef, + page: MediaServerPageRequest, + item_type: &'static str, + skip_virtual: bool, + map: impl Fn(&EmbyMapContext<'_>, &EmbyItemDto) -> Option, + ) -> Result, MediaServerError> { + let connection = self.connection().await?; + let upstream = self.fetch_item_page(&connection, library, page, item_type).await?; + let ctx = EmbyMapContext { + input_name: &self.config.input_name, + server_id: &connection.status.server_id, + library_id: &library.library_id, + include_media_sources: self.config.include_media_sources, + }; + let upstream_item_count = upstream.items.len(); + // A missing TotalRecordCount deserializes as 0; treating that as "unknown" + // keeps paging until the server returns an empty page instead of truncating. + let total = + (upstream.total_record_count > 0 || upstream.items.is_empty()).then_some(upstream.total_record_count); + let items = upstream + .items + .iter() + .filter(|item| !(skip_virtual && is_virtual_item(item))) + .filter_map(|item| map(&ctx, item)) + .collect(); + Ok(MediaServerPage::with_upstream_item_count(page, total, upstream_item_count, items)) + } +} + +#[allow(async_fn_in_trait)] +impl MediaServerCatalogClient for EmbyFamilyCatalogClient { + async fn discover(&self) -> Result { Ok(self.connection().await?.status) } + + async fn list_libraries(&self) -> Result, MediaServerError> { + let connection = self.connection().await?; + let server_id = &connection.status.server_id; + let input_name = &self.config.input_name; + if let Some(user_id) = connection.user_id.as_deref() { + let endpoint = F::user_views_path(user_id); + let query: &[(&str, &str)] = match endpoint.user_scope { + UserScope::Query => &[("UserId", user_id)], + UserScope::None | UserScope::InPath => &[], + }; + let views: EmbyItemsPageDto = + self.get_json(&self.url(endpoint.path.segments(), query)?).await?; + select_libraries(F::PROVIDER, input_name, server_id, &self.config.libraries, &views.items) + } else { + let folders: Vec = + self.get_json(&self.url(&["Library", "VirtualFolders"], &[])?).await?; + select_libraries(F::PROVIDER, input_name, server_id, &self.config.libraries, &folders) + } + } + + async fn list_movies( + &self, + library: &MediaServerLibraryRef, + page: MediaServerPageRequest, + ) -> Result, MediaServerError> { + self.list_items(library, page, "Movie", true, emby_item_to_movie::).await + } + + async fn list_series( + &self, + library: &MediaServerLibraryRef, + page: MediaServerPageRequest, + ) -> Result, MediaServerError> { + self.list_items(library, page, "Series", false, emby_item_to_series::).await + } + + async fn list_seasons( + &self, + library: &MediaServerLibraryRef, + page: MediaServerPageRequest, + ) -> Result, MediaServerError> { + self.list_items(library, page, "Season", false, emby_item_to_season::).await + } + + async fn list_episodes( + &self, + library: &MediaServerLibraryRef, + page: MediaServerPageRequest, + ) -> Result, MediaServerError> { + self.list_items(library, page, "Episode", true, emby_item_to_episode::).await + } + + async fn open_stream( + &self, + stream_ref: &MediaServerStreamRef, + range: Option<&str>, + ) -> Result { + let Some(parts) = F::stream_ref_parts(stream_ref) else { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) + .provider(F::PROVIDER) + .detail("stream open received a stream ref of another media server kind")); + }; + if parts.input_name != self.config.input_name.as_ref() { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound) + .provider(F::PROVIDER) + .detail("stream ref did not belong to the selected input")); + } + if parts.item_id.trim().is_empty() { + return Err(MediaServerError::new(MediaServerErrorKind::NoDirectPlayableMediaServerSource) + .provider(F::PROVIDER) + .detail("stream ref is missing a stable item id")); + } + let range = range + .map(str::trim) + .filter(|range| !range.is_empty()) + .map(|range| { + HeaderValue::from_str(range).map_err(|_| { + MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) + .provider(F::PROVIDER) + .detail("Range header contains invalid characters") + }) + }) + .transpose()?; + + let connection = self.connection().await?; + if parts.server_id != connection.status.server_id.as_ref() { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound) + .provider(F::PROVIDER) + .detail("stream ref did not belong to the selected server")); + } + + let mut query = vec![("static", "true")]; + if let Some(media_source_id) = parts.media_source_id.map(str::trim).filter(|id| !id.is_empty()) { + query.push(("mediaSourceId", media_source_id)); + } + let url = self.url(&["Videos", parts.item_id, "stream"], &query)?; + let response = + self.send_authenticated(MediaServerOperation::Playback, Method::GET, &url, range.as_ref()).await?; + let status = response.status(); + let headers = response.headers().clone(); + let body = response + .bytes_stream() + .map_err(|err| { + MediaServerOperation::Playback + .reqwest_error(&err, F::PROVIDER) + .detail(format!("{} stream body read failed", F::PROVIDER)) + }) + .boxed(); + Ok(MediaServerStreamResponse { status, headers, body }) + } + + async fn open_image(&self, image_ref: &MediaServerImageRef) -> Result { + let Some(parts) = F::image_ref_parts(image_ref) else { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) + .provider(F::PROVIDER) + .detail("image open received an image ref of another media server kind")); + }; + let not_found = |detail: &'static str| { + MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound).provider(F::PROVIDER).detail(detail) + }; + if parts.input_name != self.config.input_name.as_ref() { + return Err(not_found("image ref did not belong to the selected input")); + } + if parts.item_id.trim().is_empty() { + return Err(not_found("image ref is missing a stable item id")); + } + if !IMAGE_KINDS.contains(&parts.image_kind) { + return Err(not_found("image kind is not served")); + } + + let connection = self.connection().await?; + if parts.server_id != connection.status.server_id.as_ref() { + return Err(not_found("image ref did not belong to the selected server")); + } + + let tag = parts.tag.map(str::trim).filter(|tag| !tag.is_empty()); + let query: &[(&str, &str)] = if let Some(tag) = tag { &[("tag", tag)] } else { &[] }; + let url = self.url(&["Items", parts.item_id, "Images", parts.image_kind], query)?; + let response = self.send_authenticated(MediaServerOperation::Playback, Method::GET, &url, None).await?; + let status = response.status(); + let headers = response.headers().clone(); + let body = response + .bytes_stream() + .map_err(|err| { + MediaServerOperation::Playback + .reqwest_error(&err, F::PROVIDER) + .detail(format!("{} image body read failed", F::PROVIDER)) + }) + .boxed(); + Ok(MediaServerStreamResponse { status, headers, body }) + } +} + +/// Configured server URL reduced to an http(s) base that requests are appended to. +/// Query and fragment are dropped; a path prefix such as `/jellyfin` is kept. +fn parse_base_url(base_url: &str) -> Option { + let mut url = Url::parse(base_url.trim()).ok()?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() || url.cannot_be_a_base() { + return None; + } + url.set_query(None); + url.set_fragment(None); + Some(url) +} + +/// `base` with `segments` appended (percent-encoded, a trailing slash on the base +/// path does not produce an empty segment) and `query` as encoded pairs. +fn endpoint_url(base: &Url, segments: &[&str], query: &[(&str, &str)]) -> Option { + let mut url = base.clone(); + url.path_segments_mut().ok()?.pop_if_empty().extend(segments); + if !query.is_empty() { + url.query_pairs_mut().extend_pairs(query); + } + Some(url) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + emby::flavor::Emby, + jellyfin::flavor::Jellyfin, + refresh_media_server_catalog_complete_before_publish, + test_fixtures::{ + EMBY_AUTH_RESULT_JSON, EMBY_EPISODES_PAGE_JSON, EMBY_ITEMS_PAGE_JSON, EMBY_PUBLIC_SYSTEM_INFO_JSON, + EMBY_SEASONS_PAGE_JSON, EMBY_SERIES_PAGE_JSON, EMBY_VIRTUAL_FOLDERS_JSON, JELLYFIN_VIEWS_JSON, + }, + test_server::start_test_server, + MediaServerCatalogRefreshPolicy, MediaServerCatalogSnapshot, + }; + use bytes::Bytes; + use http_body_util::{BodyExt, Full}; + use hyper::{body::Incoming, http::request::Parts, Request, Response}; + use reqwest::StatusCode; + use shared::model::MediaServerLibrarySelectorDetailsDto; + use std::{ + convert::Infallible, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Mutex as StdMutex, + }, + }; + use tokio::task::JoinHandle; + + const SERVER_ID: &str = "server-redacted-id"; + const MOVIES_LIBRARY_ID: &str = "library-redacted-movies"; + const TV_LIBRARY_ID: &str = "library-redacted-tv"; + + /// Scriptable Emby/Jellyfin server. It enforces the auth scheme of `provider` + /// strictly and records every request. + struct MockServer { + provider: &'static str, + prefix: &'static str, + valid_token: StdMutex, + login_issues_valid_token: AtomicBool, + login_count: AtomicUsize, + login_bodies: StdMutex>, + legacy_auth_violations: AtomicUsize, + log: StdMutex>, + movie_total: Option, + last_range: StdMutex>, + } + + impl MockServer { + fn new(provider: &'static str, valid_token: &str) -> Self { + Self { + provider, + prefix: "", + valid_token: StdMutex::new(valid_token.to_string()), + login_issues_valid_token: AtomicBool::new(true), + login_count: AtomicUsize::new(0), + login_bodies: StdMutex::new(Vec::new()), + legacy_auth_violations: AtomicUsize::new(0), + log: StdMutex::new(Vec::new()), + movie_total: None, + last_range: StdMutex::new(None), + } + } + + fn with_prefix(mut self, prefix: &'static str) -> Self { + self.prefix = prefix; + self + } + + fn with_movie_total(mut self, total: usize) -> Self { + self.movie_total = Some(total); + self + } + + fn set_valid_token(&self, token: &str) { *self.valid_token.lock().expect("lock") = token.to_string(); } + + fn log(&self) -> Vec { self.log.lock().expect("lock").clone() } + + fn logins(&self) -> usize { self.login_count.load(Ordering::SeqCst) } + + fn is_jellyfin(&self) -> bool { self.provider == "jellyfin" } + + /// `(identity header present, token)` as the client sent them. + fn credentials(&self, parts: &Parts) -> (bool, Option) { + let header = |name: &str| parts.headers.get(name).and_then(|value| value.to_str().ok()).map(str::to_string); + if self.is_jellyfin() { + let authorization = header("authorization"); + let identity = authorization + .as_deref() + .is_some_and(|value| value.starts_with("MediaBrowser ") && value.contains("Client=\"Tuliprox\"")); + let token = authorization.as_deref().and_then(|value| { + let rest = value.split_once("Token=\"")?.1; + Some(rest.split_once('"')?.0.to_string()) + }); + (identity, token) + } else { + let identity = header("x-emby-authorization") + .is_some_and(|value| value.starts_with("MediaBrowser ") && value.contains("Device=\"Tuliprox\"")); + (identity, header("x-emby-token")) + } + } + + fn legacy_auth_used(&self, parts: &Parts) -> bool { + self.is_jellyfin() + && (parts.headers.contains_key("x-emby-token") + || parts.headers.contains_key("x-emby-authorization") + || parts.headers.contains_key("x-mediabrowser-token") + || parts.uri.query().is_some_and(|query| query.to_ascii_lowercase().contains("api_key"))) + } + + fn route(&self, parts: &Parts, body: &[u8]) -> Response> { + let Some(path) = parts.uri.path().strip_prefix(self.prefix) else { + return respond(StatusCode::NOT_FOUND, "{}"); + }; + if self.legacy_auth_used(parts) { + self.legacy_auth_violations.fetch_add(1, Ordering::SeqCst); + return respond(StatusCode::UNAUTHORIZED, "{}"); + } + let (has_identity, token) = self.credentials(parts); + if !has_identity { + return respond(StatusCode::BAD_REQUEST, "missing client identity"); + } + let query: Vec<(String, String)> = + url::form_urlencoded::parse(parts.uri.query().unwrap_or_default().as_bytes()).into_owned().collect(); + let segments: Vec<&str> = path.trim_matches('/').split('/').collect(); + + match (parts.method.as_str(), segments.as_slice()) { + ("GET", ["System", "Info", "Public"]) if token.is_none() => { + respond(StatusCode::OK, EMBY_PUBLIC_SYSTEM_INFO_JSON) + } + ("POST", ["Users", "AuthenticateByName"]) if token.is_none() => { + let count = self.login_count.fetch_add(1, Ordering::SeqCst) + 1; + self.login_bodies.lock().expect("lock").push(String::from_utf8_lossy(body).into_owned()); + let issued = format!("login-token-{count}"); + if self.login_issues_valid_token.load(Ordering::SeqCst) { + self.set_valid_token(&issued); + } + respond(StatusCode::OK, &EMBY_AUTH_RESULT_JSON.replace("token-redacted", &issued)) + } + ("GET", ["System", "Info", "Public"] | ["Users", "AuthenticateByName"]) => { + respond(StatusCode::BAD_REQUEST, "anonymous endpoint received a token") + } + _ if token.as_deref() != Some(self.valid_token.lock().expect("lock").as_str()) => { + respond(StatusCode::UNAUTHORIZED, "{}") + } + ("GET", ["Library", "VirtualFolders"]) => respond(StatusCode::OK, EMBY_VIRTUAL_FOLDERS_JSON), + ("GET", ["UserViews"] | ["Users", _, "Views"]) => respond(StatusCode::OK, JELLYFIN_VIEWS_JSON), + ("GET", ["Items"] | ["Users", _, "Items"]) => respond(StatusCode::OK, &self.items_body(&query)), + ("GET", ["Videos", _, "stream"]) => { + let range = parts.headers.get("range").and_then(|value| value.to_str().ok()).map(str::to_string); + let ranged = range.is_some(); + *self.last_range.lock().expect("lock") = range; + // Headers the proxy must never pass on to a player. + let mut headers = vec![ + ("content-type", "video/x-matroska"), + ("accept-ranges", "bytes"), + ("x-emby-token", "leaked-token"), + ("authorization", "leaked-authorization"), + ("set-cookie", "leaked=cookie"), + ]; + if ranged { + headers.push(("content-range", "bytes 5-8/9")); + } + let status = if ranged { StatusCode::PARTIAL_CONTENT } else { StatusCode::OK }; + respond_with(status, "data", &headers) + } + ("GET", ["Items", _, "Images", _]) => { + respond_with(StatusCode::OK, "image-bytes", &[("content-type", "image/jpeg")]) + } + _ => respond(StatusCode::NOT_FOUND, "{}"), + } + } + + fn items_body(&self, query: &[(String, String)]) -> String { + let param = |name: &str| query.iter().find(|(key, _)| key == name).map(|(_, value)| value.as_str()); + match param("IncludeItemTypes") { + Some("Movie") => match self.movie_total { + Some(total) => { + let start: usize = param("StartIndex").and_then(|value| value.parse().ok()).unwrap_or(0); + let limit: usize = param("Limit").and_then(|value| value.parse().ok()).unwrap_or(0); + let items = (start..total.min(start + limit)) + .map(|index| format!(r#"{{"Id":"movie-{index}","Name":"Movie {index}","Type":"Movie"}}"#)) + .collect::>() + .join(","); + format!(r#"{{"Items":[{items}],"TotalRecordCount":{total},"StartIndex":{start}}}"#) + } + None => EMBY_ITEMS_PAGE_JSON.to_string(), + }, + Some("Series") => EMBY_SERIES_PAGE_JSON.to_string(), + Some("Season") => EMBY_SEASONS_PAGE_JSON.to_string(), + Some("Episode") => EMBY_EPISODES_PAGE_JSON.to_string(), + _ => r#"{"Items":[],"TotalRecordCount":0}"#.to_string(), + } + } + } + + fn respond(status: StatusCode, body: &str) -> Response> { + respond_with(status, body, &[("content-type", "application/json")]) + } + + fn respond_with(status: StatusCode, body: &str, headers: &[(&str, &str)]) -> Response> { + let mut builder = Response::builder().status(status); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + builder.body(Full::new(Bytes::from(body.to_string()))).expect("response builds") + } + + async fn handle(state: Arc, request: Request) -> Result>, Infallible> { + let (parts, body) = request.into_parts(); + let body = body.collect().await.map(http_body_util::Collected::to_bytes).unwrap_or_default(); + let target = parts.uri.path_and_query().map(ToString::to_string).unwrap_or_default(); + state.log.lock().expect("lock").push(format!("{} {target}", parts.method)); + Ok(state.route(&parts, &body)) + } + + async fn serve(state: &Arc) -> (String, JoinHandle<()>) { + let state = Arc::clone(state); + start_test_server(move |request| handle(Arc::clone(&state), request)).await + } + + fn http() -> MediaServerHttpClient { + MediaServerHttpClient::new(reqwest::Client::builder().no_proxy().build().expect("client builds")) + } + + fn movies_only() -> Vec { vec![MediaServerLibrarySelector::Name("Movies".to_string())] } + + fn token_settings(token: &str) -> EmbyClientSettings { + EmbyClientSettings { token: Some(token.to_string()), libraries: movies_only(), ..EmbyClientSettings::default() } + } + + fn login_settings() -> EmbyClientSettings { + EmbyClientSettings { + username: Some("alice".to_string()), + password: Some("pw".to_string()), + libraries: movies_only(), + ..EmbyClientSettings::default() + } + } + + fn client( + input_name: &str, + base_url: &str, + settings: &EmbyClientSettings, + ) -> EmbyFamilyCatalogClient { + EmbyFamilyCatalogClient::new(Arc::from(input_name), base_url, settings, http()).expect("client builds") + } + + async fn refresh( + client: &C, + page_size: usize, + ) -> Result { + refresh_media_server_catalog_complete_before_publish( + client, + MediaServerCatalogRefreshPolicy { page_size, request_delay_ms: 0 }, + ) + .await + } + + fn library_ref(input_name: &str, library_id: &str) -> MediaServerLibraryRef { + MediaServerLibraryRef { + input_name: Arc::from(input_name), + server_id: Arc::from(SERVER_ID), + library_id: Arc::from(library_id), + } + } + + fn query_value(entry: &str, name: &str) -> Option { + let query = entry.split_once('?')?.1; + url::form_urlencoded::parse(query.as_bytes()).find(|(key, _)| key == name).map(|(_, value)| value.into_owned()) + } + + fn item_requests(log: &[String]) -> Vec<&String> { log.iter().filter(|entry| entry.contains("/Items?")).collect() } + + async fn token_import_succeeds(input_name: &str) { + let state = Arc::new(MockServer::new(F::PROVIDER, "secret")); + let (base_url, server) = serve(&state).await; + let client = client::(input_name, &base_url, &token_settings("secret")); + + let snapshot = refresh(&client, 100).await.expect("refresh succeeds"); + + assert_eq!(snapshot.movies.len(), 1); + assert_eq!(snapshot.movies[0].server_id.as_ref(), SERVER_ID); + assert_eq!(state.legacy_auth_violations.load(Ordering::SeqCst), 0); + assert_eq!(state.logins(), 0); + assert!(state.log().iter().all(|entry| !entry.contains("secret")), "token must never be part of a URL"); + server.abort(); + } + + #[tokio::test] + async fn jellyfin_import_uses_authorization_header_only() { + token_import_succeeds::("jellyfin-header-only").await; + } + + #[tokio::test] + async fn emby_import_uses_x_emby_token() { token_import_succeeds::("emby-x-emby-token").await; } + + async fn prefix_is_preserved(input_name: &str, suffix: &str) { + let state = Arc::new(MockServer::new(F::PROVIDER, "secret").with_prefix("/media/pre")); + let (base_url, server) = serve(&state).await; + let client = client::(input_name, &format!("{base_url}/media/pre{suffix}"), &token_settings("secret")); + + let snapshot = refresh(&client, 100).await.expect("refresh succeeds under the prefix"); + + assert_eq!(snapshot.movies.len(), 1); + let log = state.log(); + assert_eq!(log.len(), 3, "discovery, libraries and items were requested: {log:?}"); + assert!(log.iter().all(|entry| entry.split(' ').nth(1).is_some_and(|path| path.starts_with("/media/pre/")))); + server.abort(); + } + + #[tokio::test] + async fn catalog_requests_keep_base_url_path_prefix() { + prefix_is_preserved::("prefix-jellyfin-slash", "/").await; + prefix_is_preserved::("prefix-jellyfin-bare", "").await; + prefix_is_preserved::("prefix-emby-slash", "/").await; + prefix_is_preserved::("prefix-emby-bare", "").await; + } + + async fn api_key_without_user(input_name: &str) { + let state = Arc::new(MockServer::new(F::PROVIDER, "api-secret")); + let (base_url, server) = serve(&state).await; + let settings = EmbyClientSettings { + api_key: Some("api-secret".to_string()), + libraries: movies_only(), + ..EmbyClientSettings::default() + }; + let client = client::(input_name, &base_url, &settings); + + let snapshot = refresh(&client, 100).await.expect("refresh succeeds"); + + assert_eq!(snapshot.movies.len(), 1); + let log = state.log(); + assert!(log.iter().any(|entry| entry == "GET /Library/VirtualFolders")); + assert!(log.iter().all(|entry| !entry.contains("/UserViews") && !entry.contains("/Users/"))); + let items = item_requests(&log); + assert_eq!(items.len(), 1); + assert_eq!(query_value(items[0], "UserId"), None); + assert_eq!(query_value(items[0], "ParentId").as_deref(), Some(MOVIES_LIBRARY_ID)); + server.abort(); + } + + #[tokio::test] + async fn api_key_without_user_uses_virtual_folders_and_unscoped_items() { + api_key_without_user::("api-key-jellyfin").await; + api_key_without_user::("api-key-emby").await; + } + + #[tokio::test] + async fn user_id_uses_user_views_and_user_scoped_items() { + for provider in ["jellyfin", "emby"] { + let state = Arc::new(MockServer::new(provider, "secret")); + let (base_url, server) = serve(&state).await; + let settings = EmbyClientSettings { user_id: Some("user 1".to_string()), ..token_settings("secret") }; + let name = format!("user-scoped-{provider}"); + let snapshot = if provider == "jellyfin" { + refresh(&client::(&name, &base_url, &settings), 100).await + } else { + refresh(&client::(&name, &base_url, &settings), 100).await + } + .expect("refresh succeeds"); + + assert_eq!(snapshot.movies.len(), 1); + let log = state.log(); + assert!(log.iter().all(|entry| !entry.contains("VirtualFolders"))); + let items = item_requests(&log); + assert_eq!(items.len(), 1); + if provider == "jellyfin" { + assert!(log.iter().any(|entry| entry == "GET /UserViews?UserId=user+1")); + assert!(items[0].starts_with("GET /Items?")); + assert_eq!(query_value(items[0], "UserId").as_deref(), Some("user 1")); + } else { + assert!(log.iter().any(|entry| entry == "GET /Users/user%201/Views")); + assert!(items[0].starts_with("GET /Users/user%201/Items?")); + assert_eq!(query_value(items[0], "UserId"), None); + } + server.abort(); + } + } + + #[tokio::test] + async fn item_requests_carry_the_pinned_query() { + let state = Arc::new(MockServer::new("jellyfin", "secret")); + let (base_url, server) = serve(&state).await; + let settings = EmbyClientSettings { include_media_sources: true, ..token_settings("secret") }; + let client = client::("pinned-query", &base_url, &settings); + + refresh(&client, 7).await.expect("refresh succeeds"); + + let log = state.log(); + let items = item_requests(&log); + let entry = items[0]; + let expected = [ + ("IncludeItemTypes", "Movie"), + ("Recursive", "true"), + ("StartIndex", "0"), + ("Limit", "7"), + ("EnableTotalRecordCount", "true"), + ("SortBy", "SortName"), + ("SortOrder", "Ascending"), + ("CollapseBoxSetItems", "false"), + ]; + for (name, value) in expected { + assert_eq!(query_value(entry, name).as_deref(), Some(value), "query parameter {name}"); + } + let fields = query_value(entry, "Fields").expect("Fields requested"); + assert!(fields.starts_with("ProviderIds,Overview,") && fields.ends_with(",MediaSources")); + server.abort(); + } + + async fn concurrent_login(input_name: &str) { + let state = Arc::new(MockServer::new(F::PROVIDER, "unused")); + let (base_url, server) = serve(&state).await; + let first = client::(input_name, &base_url, &login_settings()); + let second = client::(input_name, &base_url, &login_settings()); + + let (first_snapshot, second_snapshot) = tokio::join!(refresh(&first, 100), refresh(&second, 100)); + + assert_eq!(first_snapshot.expect("first refresh").movies.len(), 1); + assert_eq!(second_snapshot.expect("second refresh").movies.len(), 1); + assert_eq!(state.logins(), 1, "login is single-flight"); + assert_eq!(state.login_bodies.lock().expect("lock").as_slice(), [r#"{"Username":"alice","Pw":"pw"}"#]); + // The login result carries the user id, so the user-scoped endpoints are used. + assert!(state.log().iter().any(|entry| entry.contains("user-redacted-id"))); + assert_eq!(state.legacy_auth_violations.load(Ordering::SeqCst), 0); + server.abort(); + } + + #[tokio::test] + async fn login_bootstraps_token_and_user_id_once_for_concurrent_clients() { + concurrent_login::("concurrent-login-jellyfin").await; + concurrent_login::("concurrent-login-emby").await; + } + + async fn relogin(input_name: &str) { + let state = Arc::new(MockServer::new(F::PROVIDER, "unused")); + let (base_url, server) = serve(&state).await; + let client = client::(input_name, &base_url, &login_settings()); + let library = library_ref(input_name, MOVIES_LIBRARY_ID); + client.list_libraries().await.expect("first login works"); + assert_eq!(state.logins(), 1); + + state.set_valid_token("rotated-by-server"); + let page = client.list_movies(&library, MediaServerPageRequest::new(0, 100)).await.expect("re-login heals"); + + assert_eq!(page.items.len(), 1); + assert_eq!(state.logins(), 2); + // The healed session is reused instead of logging in per request. + client.list_movies(&library, MediaServerPageRequest::new(0, 100)).await.expect("session is reused"); + assert_eq!(state.logins(), 2); + server.abort(); + } + + #[tokio::test] + async fn expired_login_token_relogs_once() { + relogin::("relogin-jellyfin").await; + relogin::("relogin-emby").await; + } + + #[tokio::test] + async fn second_unauthorized_after_relogin_is_auth_denied_without_third_login() { + let state = Arc::new(MockServer::new("jellyfin", "unused")); + let (base_url, server) = serve(&state).await; + let client = client::("relogin-denied", &base_url, &login_settings()); + let library = library_ref("relogin-denied", MOVIES_LIBRARY_ID); + client.list_libraries().await.expect("first login works"); + state.login_issues_valid_token.store(false, Ordering::SeqCst); + state.set_valid_token("rotated-by-server"); + + let error = client + .list_movies(&library, MediaServerPageRequest::new(0, 100)) + .await + .expect_err("second 401 is not retried"); + + assert_eq!(error.kind, MediaServerErrorKind::MediaServerAuthDenied); + assert_eq!(error.status, Some(StatusCode::UNAUTHORIZED)); + assert_eq!(state.logins(), 2); + server.abort(); + } + + #[tokio::test] + async fn configured_token_401_is_auth_denied_without_login() { + for provider in ["jellyfin", "emby"] { + let state = Arc::new(MockServer::new(provider, "server-side-token")); + let (base_url, server) = serve(&state).await; + let settings = EmbyClientSettings { + username: Some("alice".to_string()), + password: Some("pw".to_string()), + ..token_settings("stale-token") + }; + let name = format!("configured-401-{provider}"); + let error = if provider == "jellyfin" { + client::(&name, &base_url, &settings).list_libraries().await + } else { + client::(&name, &base_url, &settings).list_libraries().await + } + .expect_err("configured token is rejected"); + + assert_eq!(error.kind, MediaServerErrorKind::MediaServerAuthDenied); + assert_eq!(state.logins(), 0); + assert_eq!(state.log().iter().filter(|entry| entry.contains("VirtualFolders")).count(), 1); + server.abort(); + } + } + + #[tokio::test] + async fn rejected_login_is_auth_denied() { + let (base_url, server) = start_test_server(|request| async move { + let status = if request.uri().path() == "/Users/AuthenticateByName" { + StatusCode::UNAUTHORIZED + } else { + StatusCode::OK + }; + let body = if status == StatusCode::OK { EMBY_PUBLIC_SYSTEM_INFO_JSON } else { "{}" }; + Ok(respond(status, body)) + }) + .await; + let client = client::("login-rejected", &base_url, &login_settings()); + + let error = client.discover().await.expect_err("login is rejected"); + + assert_eq!(error.kind, MediaServerErrorKind::MediaServerAuthDenied); + server.abort(); + } + + #[tokio::test] + async fn paging_follows_total_record_count() { + let state = Arc::new(MockServer::new("jellyfin", "secret").with_movie_total(5)); + let (base_url, server) = serve(&state).await; + let client = client::("paging", &base_url, &token_settings("secret")); + + let snapshot = refresh(&client, 2).await.expect("refresh succeeds"); + + assert_eq!(snapshot.movies.len(), 5); + let log = state.log(); + let starts: Vec<_> = + item_requests(&log).into_iter().filter_map(|entry| query_value(entry, "StartIndex")).collect(); + assert_eq!(starts, ["0", "2", "4"]); + server.abort(); + } + + #[tokio::test] + async fn selector_for_mixed_library_is_reported_unsupported() { + let state = Arc::new(MockServer::new("emby", "api-secret")); + let (base_url, server) = serve(&state).await; + let settings = EmbyClientSettings { + api_key: Some("api-secret".to_string()), + libraries: vec![MediaServerLibrarySelector::Name("Mixed".to_string())], + ..EmbyClientSettings::default() + }; + let client = client::("mixed-library", &base_url, &settings); + + let snapshot = refresh(&client, 100).await.expect("refresh succeeds"); + + assert_eq!(snapshot.unsupported_libraries.len(), 1); + assert_eq!(snapshot.unsupported_libraries[0].reference.library_id.as_ref(), "library-redacted-mixed"); + assert_eq!(snapshot.movies.len(), 0); + assert_eq!(item_requests(&state.log()).len(), 0); + server.abort(); + } + + #[tokio::test] + async fn detailed_selector_with_wrong_kind_is_library_unavailable() { + let state = Arc::new(MockServer::new("emby", "api-secret")); + let (base_url, server) = serve(&state).await; + let settings = EmbyClientSettings { + api_key: Some("api-secret".to_string()), + libraries: vec![MediaServerLibrarySelector::Detailed(MediaServerLibrarySelectorDetailsDto { + name: Some("Movies".to_string()), + kind: Some(shared::model::MediaServerLibraryKind::TvShows), + ..MediaServerLibrarySelectorDetailsDto::default() + })], + ..EmbyClientSettings::default() + }; + let client = client::("wrong-kind", &base_url, &settings); + + let error = client.list_libraries().await.expect_err("kind mismatch is not visible"); + + assert_eq!(error.kind, MediaServerErrorKind::MediaServerLibraryUnavailable); + server.abort(); + } + + #[tokio::test] + async fn virtual_episode_filtered_but_upstream_counted_in_page() { + let state = Arc::new(MockServer::new("jellyfin", "secret")); + let (base_url, server) = serve(&state).await; + let settings = EmbyClientSettings { include_media_sources: true, ..token_settings("secret") }; + let client = client::("virtual-episode", &base_url, &settings); + + let page = client + .list_episodes(&library_ref("virtual-episode", TV_LIBRARY_ID), MediaServerPageRequest::new(0, 10)) + .await + .expect("episodes load"); + + assert_eq!(page.upstream_item_count(), 3); + assert_eq!(page.total, Some(3)); + assert_eq!(page.items.len(), 2); + assert!(page.items.iter().all(|episode| !episode.title.contains("missing"))); + assert!(page.next_request().is_none()); + server.abort(); + } + + #[tokio::test] + async fn invalid_token_chars_rejected_as_auth_denied() { + for token in ["bad\"token", "bad token", "bad\ntoken"] { + let state = Arc::new(MockServer::new("jellyfin", "unused")); + let (base_url, server) = serve(&state).await; + let client = client::("invalid-token-chars", &base_url, &token_settings(token)); + + let error = client.discover().await.expect_err("token is rejected"); + + assert_eq!(error.kind, MediaServerErrorKind::MediaServerAuthDenied, "token {token:?}"); + assert!(state.log().iter().all(|entry| !entry.contains("/Items") && !entry.contains("/Library"))); + server.abort(); + } + } + + #[tokio::test] + async fn missing_credentials_are_auth_denied() { + let state = Arc::new(MockServer::new("emby", "unused")); + let (base_url, server) = serve(&state).await; + let client = client::("no-credentials", &base_url, &EmbyClientSettings::default()); + + let error = client.discover().await.expect_err("no credentials"); + + assert_eq!(error.kind, MediaServerErrorKind::MediaServerAuthDenied); + server.abort(); + } + + #[tokio::test] + async fn discovery_without_any_server_id_fails() { + let (base_url, server) = + start_test_server(|_request| async move { Ok(respond(StatusCode::OK, r#"{"ServerName":"x"}"#)) }).await; + let client = client::("no-server-id", &base_url, &token_settings("secret")); + + let error = client.discover().await.expect_err("server id is mandatory"); + + assert_eq!(error.kind, MediaServerErrorKind::MediaServerDiscoveryFailed); + server.abort(); + } + + const ITEM_ID: &str = "movie 1"; + + fn stream_ref_for( + input_name: &str, + server_id: &str, + item_id: &str, + media_source_id: Option<&str>, + ) -> MediaServerStreamRef { + F::stream_ref( + &Arc::from(input_name), + &Arc::from(server_id), + &Arc::from(item_id), + media_source_id.map(Arc::from), + ) + } + + fn image_ref_for( + input_name: &str, + server_id: &str, + item_id: &str, + image_kind: &str, + tag: Option<&str>, + ) -> MediaServerImageRef { + F::image_ref( + &Arc::from(input_name), + &Arc::from(server_id), + &Arc::from(item_id), + Arc::from(image_kind), + tag.map(Arc::from), + ) + } + + async fn collect_body(response: MediaServerStreamResponse) -> Vec { + let chunks: Vec<_> = response.body.collect().await; + chunks.into_iter().flat_map(|chunk| chunk.expect("chunk reads").to_vec()).collect() + } + + async fn stream_forwards_range(input_name: &str) { + let state = Arc::new(MockServer::new(F::PROVIDER, "secret")); + let (base_url, server) = serve(&state).await; + let client = client::(input_name, &base_url, &token_settings("secret")); + let stream_ref = stream_ref_for::(input_name, SERVER_ID, ITEM_ID, Some("ms-1")); + + let response = client.open_stream(&stream_ref, Some("bytes=5-8")).await.expect("stream opens"); + + assert_eq!(response.status, StatusCode::PARTIAL_CONTENT); + assert_eq!( + response.headers.get(reqwest::header::CONTENT_RANGE).and_then(|value| value.to_str().ok()), + Some("bytes 5-8/9") + ); + assert_eq!(collect_body(response).await, b"data"); + assert_eq!(state.last_range.lock().expect("lock").as_deref(), Some("bytes=5-8")); + let log = state.log(); + assert_eq!(log.last().map(String::as_str), Some("GET /Videos/movie%201/stream?static=true&mediaSourceId=ms-1")); + assert!(log.iter().all(|entry| !entry.contains("secret")), "token must never be part of a URL"); + assert_eq!(state.legacy_auth_violations.load(Ordering::SeqCst), 0); + + // No Range and no media source: plain 200 and a bare `static=true` query. + let plain = stream_ref_for::(input_name, SERVER_ID, ITEM_ID, None); + let response = client.open_stream(&plain, Some(" ")).await.expect("plain stream opens"); + assert_eq!(response.status, StatusCode::OK); + assert_eq!(state.last_range.lock().expect("lock").as_deref(), None); + assert_eq!(state.log().last().map(String::as_str), Some("GET /Videos/movie%201/stream?static=true")); + server.abort(); + } + + #[test] + fn settings_debug_hides_secrets() { + let settings = EmbyClientSettings { + token: Some("tok-secret".to_string()), + api_key: Some("key-secret".to_string()), + user_id: Some("user-1".to_string()), + username: Some("alice".to_string()), + password: Some("pw-secret".to_string()), + ..EmbyClientSettings::default() + }; + + let debug = format!("{settings:?}"); + + for secret in ["tok-secret", "key-secret", "pw-secret"] { + assert!(!debug.contains(secret), "{secret} leaked into {debug}"); + } + assert!(debug.contains("token: Some(\"***\")") && debug.contains("password: Some(\"***\")"), "{debug}"); + assert!(debug.contains("user-1") && debug.contains("alice"), "{debug}"); + assert!(format!("{:?}", EmbyClientSettings::default()).contains("token: None")); + } + + #[tokio::test] + async fn open_stream_forwards_range_and_media_source_id() { + stream_forwards_range::("stream-range-jellyfin").await; + stream_forwards_range::("stream-range-emby").await; + } + + #[tokio::test] + async fn open_stream_under_path_prefix() { + let state = Arc::new(MockServer::new("jellyfin", "secret").with_prefix("/media/pre")); + let (base_url, server) = serve(&state).await; + let client = client::("stream-prefix", &format!("{base_url}/media/pre/"), &token_settings("secret")); + let stream_ref = stream_ref_for::("stream-prefix", SERVER_ID, "movie-1", None); + + let response = client.open_stream(&stream_ref, None).await.expect("stream opens under the prefix"); + + assert_eq!(response.status, StatusCode::OK); + assert_eq!(state.log().last().map(String::as_str), Some("GET /media/pre/Videos/movie-1/stream?static=true")); + server.abort(); + } + + #[tokio::test] + async fn open_stream_rejects_foreign_input_server_and_flavor() { + let state = Arc::new(MockServer::new("emby", "secret")); + let (base_url, server) = serve(&state).await; + let client = client::("stream-foreign", &base_url, &token_settings("secret")); + let kind_of = + |result: Result| result.expect_err("stream is rejected").kind; + + assert_eq!( + kind_of(client.open_stream(&stream_ref_for::("other-input", SERVER_ID, "item", None), None).await), + MediaServerErrorKind::MediaServerItemNotFound + ); + assert_eq!( + kind_of( + client.open_stream(&stream_ref_for::("stream-foreign", "other-server", "item", None), None).await + ), + MediaServerErrorKind::MediaServerItemNotFound + ); + assert_eq!( + kind_of(client.open_stream(&stream_ref_for::("stream-foreign", SERVER_ID, " ", None), None).await), + MediaServerErrorKind::NoDirectPlayableMediaServerSource + ); + assert_eq!( + kind_of( + client.open_stream(&stream_ref_for::("stream-foreign", SERVER_ID, "item", None), None).await + ), + MediaServerErrorKind::MediaServerStreamOpenFailed + ); + assert_eq!( + kind_of( + client + .open_stream( + &stream_ref_for::("stream-foreign", SERVER_ID, "item", None), + Some("bytes=0-\n1") + ) + .await + ), + MediaServerErrorKind::MediaServerStreamOpenFailed + ); + assert!( + state.log().iter().all(|entry| !entry.contains("/Videos/")), + "no rejected ref reaches the stream endpoint" + ); + server.abort(); + } + + #[tokio::test] + async fn open_stream_relogs_once_on_expired_login_token() { + let state = Arc::new(MockServer::new("jellyfin", "unused")); + let (base_url, server) = serve(&state).await; + let client = client::("stream-relogin", &base_url, &login_settings()); + let stream_ref = stream_ref_for::("stream-relogin", SERVER_ID, "movie-1", None); + client.open_stream(&stream_ref, None).await.expect("first stream opens"); + assert_eq!(state.logins(), 1); + + state.set_valid_token("rotated-by-server"); + let response = client.open_stream(&stream_ref, Some("bytes=5-8")).await.expect("re-login heals the stream"); + + assert_eq!(response.status, StatusCode::PARTIAL_CONTENT); + assert_eq!(state.logins(), 2); + assert_eq!(state.last_range.lock().expect("lock").as_deref(), Some("bytes=5-8"), "Range survives the retry"); + server.abort(); + } + + #[tokio::test] + async fn stream_errors_never_contain_the_token() { + for (provider, status_token) in [("jellyfin", "secret-stream-token"), ("emby", "secret-stream-token")] { + let state = Arc::new(MockServer::new(provider, "server-side-token")); + let (base_url, server) = serve(&state).await; + let name = format!("stream-denied-{provider}"); + let settings = token_settings(status_token); + let error = if provider == "jellyfin" { + let client = client::(&name, &base_url, &settings); + client.open_stream(&stream_ref_for::(&name, SERVER_ID, "movie-1", None), None).await + } else { + let client = client::(&name, &base_url, &settings); + client.open_stream(&stream_ref_for::(&name, SERVER_ID, "movie-1", None), None).await + } + .expect_err("configured token is rejected"); + + assert_eq!(error.kind, MediaServerErrorKind::MediaServerAuthDenied); + assert!(!error.to_string().contains(status_token), "{error}"); + assert!(!format!("{error:?}").contains(status_token)); + server.abort(); + } + } + + async fn image_with_tag(input_name: &str) { + let state = Arc::new(MockServer::new(F::PROVIDER, "secret")); + let (base_url, server) = serve(&state).await; + let client = client::(input_name, &base_url, &token_settings("secret")); + + let tagged = image_ref_for::(input_name, SERVER_ID, ITEM_ID, "Primary", Some("image tag")); + let response = client.open_image(&tagged).await.expect("image opens"); + + assert_eq!(response.status, StatusCode::OK); + let chunks = response.body.collect::>().await; + let body: Vec = chunks.into_iter().flat_map(|chunk| chunk.expect("image chunk").to_vec()).collect(); + assert_eq!(body, b"image-bytes"); + assert_eq!( + response.headers.get(reqwest::header::CONTENT_TYPE).and_then(|value| value.to_str().ok()), + Some("image/jpeg") + ); + assert_eq!(state.log().last().map(String::as_str), Some("GET /Items/movie%201/Images/Primary?tag=image+tag")); + + let untagged = image_ref_for::(input_name, SERVER_ID, ITEM_ID, "Backdrop", None); + client.open_image(&untagged).await.expect("untagged image opens"); + assert_eq!(state.log().last().map(String::as_str), Some("GET /Items/movie%201/Images/Backdrop")); + assert_eq!(state.legacy_auth_violations.load(Ordering::SeqCst), 0); + server.abort(); + } + + #[tokio::test] + async fn open_image_requests_primary_with_tag() { + image_with_tag::("image-jellyfin").await; + image_with_tag::("image-emby").await; + } + + #[tokio::test] + async fn open_image_rejects_unknown_kind() { + let state = Arc::new(MockServer::new("emby", "secret")); + let (base_url, server) = serve(&state).await; + let client = client::("image-kind", &base_url, &token_settings("secret")); + + for kind in ["primary", "Chapter", "Primary/../Users", "", "Primary?x=1"] { + let image_ref = image_ref_for::("image-kind", SERVER_ID, "movie-1", kind, None); + + let error = client.open_image(&image_ref).await.expect_err("kind is not served"); + + assert_eq!(error.kind, MediaServerErrorKind::MediaServerItemNotFound, "kind {kind:?}"); + } + for (input, server_id, item) in + [("other", SERVER_ID, "movie-1"), ("image-kind", "other-server", "movie-1"), ("image-kind", SERVER_ID, " ")] + { + let image_ref = image_ref_for::(input, server_id, item, "Primary", None); + + let error = client.open_image(&image_ref).await.expect_err("ref is rejected"); + + assert_eq!(error.kind, MediaServerErrorKind::MediaServerItemNotFound); + } + let foreign = image_ref_for::("image-kind", SERVER_ID, "movie-1", "Primary", None); + assert_eq!( + client.open_image(&foreign).await.expect_err("flavor mismatch").kind, + MediaServerErrorKind::MediaServerStreamOpenFailed + ); + assert!(state.log().iter().all(|entry| !entry.contains("/Images/"))); + server.abort(); + } + + #[tokio::test] + async fn proxied_responses_do_not_carry_credential_headers() { + use crate::{media_server_image_response, media_server_stream_response}; + + for provider in ["jellyfin", "emby"] { + let state = Arc::new(MockServer::new(provider, "secret")); + let (base_url, server) = serve(&state).await; + let name = format!("proxy-{provider}"); + let (stream_headers, status, image_headers) = if provider == "jellyfin" { + let client = client::(&name, &base_url, &token_settings("secret")); + let stream_ref = stream_ref_for::(&name, SERVER_ID, "movie-1", None); + let stream = + media_server_stream_response(&client, &stream_ref, Some("bytes=5-8")).await.expect("proxy"); + let image_ref = image_ref_for::(&name, SERVER_ID, "movie-1", "Primary", None); + let image = media_server_image_response(&client, &image_ref).await.expect("image proxy"); + (stream.headers, stream.status, image.headers) + } else { + let client = client::(&name, &base_url, &token_settings("secret")); + let stream_ref = stream_ref_for::(&name, SERVER_ID, "movie-1", None); + let stream = + media_server_stream_response(&client, &stream_ref, Some("bytes=5-8")).await.expect("proxy"); + let image_ref = image_ref_for::(&name, SERVER_ID, "movie-1", "Primary", None); + let image = media_server_image_response(&client, &image_ref).await.expect("image proxy"); + (stream.headers, stream.status, image.headers) + }; + + assert_eq!(status, StatusCode::PARTIAL_CONTENT); + assert!(stream_headers.contains_key(reqwest::header::CONTENT_RANGE)); + for headers in [&stream_headers, &image_headers] { + for name in headers.keys() { + let name = name.as_str(); + assert!( + name != "authorization" && !name.starts_with("x-emby-") && !name.starts_with("x-mediabrowser-"), + "credential header {name} leaked into the proxied response" + ); + } + assert!(!headers.contains_key("set-cookie")); + } + server.abort(); + } + } + + #[tokio::test] + async fn slow_server_does_not_delay_connecting_other_inputs() { + use tokio::sync::Notify; + + let entered = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let (slow_url, slow_server) = { + let (entered, release) = (Arc::clone(&entered), Arc::clone(&release)); + start_test_server(move |_request| { + let (entered, release) = (Arc::clone(&entered), Arc::clone(&release)); + async move { + entered.notify_one(); + release.notified().await; + Ok(respond(StatusCode::OK, EMBY_PUBLIC_SYSTEM_INFO_JSON)) + } + }) + .await + }; + let fast_state = Arc::new(MockServer::new("emby", "secret")); + let (fast_url, fast_server) = serve(&fast_state).await; + let slow_client = client::("isolation-slow", &slow_url, &token_settings("secret")); + let fast_client = client::("isolation-fast", &fast_url, &token_settings("secret")); + + let slow_task = tokio::spawn(async move { slow_client.discover().await }); + tokio::time::timeout(std::time::Duration::from_secs(5), entered.notified()) + .await + .expect("slow discovery reached its server"); + + let fast = tokio::time::timeout(std::time::Duration::from_secs(5), fast_client.discover()) + .await + .expect("other input connects while the slow server is still stalled") + .expect("fast discovery succeeds"); + + assert_eq!(fast.server_id.as_ref(), SERVER_ID); + assert!(!slow_task.is_finished(), "the slow connection is still pending"); + release.notify_one(); + let slow = slow_task.await.expect("task joins").expect("slow discovery succeeds after release"); + assert_eq!(slow.server_id.as_ref(), SERVER_ID); + slow_server.abort(); + fast_server.abort(); + } + #[test] + fn endpoint_url_keeps_path_prefix_with_and_without_trailing_slash() { + for base in ["https://host.example.invalid/jellyfin", "https://host.example.invalid/jellyfin/"] { + let base = parse_base_url(base).expect("base parses"); + + let url = endpoint_url(&base, &["System", "Info", "Public"], &[]).expect("url builds"); + + assert_eq!(url.as_str(), "https://host.example.invalid/jellyfin/System/Info/Public"); + } + } + + #[test] + fn endpoint_url_on_bare_host_has_no_empty_segment() { + let base = parse_base_url("http://127.0.0.1:8096").expect("base parses"); + + let url = endpoint_url(&base, &["Items"], &[("ParentId", "a b&c")]).expect("url builds"); + + assert_eq!(url.as_str(), "http://127.0.0.1:8096/Items?ParentId=a+b%26c"); + } + + #[test] + fn endpoint_url_encodes_segments_and_ignores_traversal() { + let base = parse_base_url("http://127.0.0.1:8096/emby/").expect("base parses"); + + let url = endpoint_url(&base, &["Users", "a/b?c", "..", "Items"], &[]).expect("url builds"); + + assert_eq!(url.as_str(), "http://127.0.0.1:8096/emby/Users/a%2Fb%3Fc/Items"); + } + + #[test] + fn base_url_drops_query_and_fragment_and_rejects_non_http() { + let base = parse_base_url("http://host.example.invalid/emby?api_key=secret#frag").expect("base parses"); + + assert_eq!(base.as_str(), "http://host.example.invalid/emby"); + for invalid in ["", " ", "not a url", "ftp://host.example.invalid", "mailto:user@host.example.invalid"] { + assert!(parse_base_url(invalid).is_none(), "{invalid:?}"); + } + } + + #[test] + fn new_rejects_invalid_base_url_as_discovery_failed() { + let result = EmbyFamilyCatalogClient::::new(Arc::from("x"), "", &token_settings("t"), http()); + + assert_eq!(result.err().map(|error| error.kind), Some(MediaServerErrorKind::MediaServerDiscoveryFailed)); + } + + #[test] + fn cache_key_holds_no_plaintext_secrets_and_changes_with_credentials() { + let settings = EmbyClientSettings { + token: Some("plain-token".to_string()), + api_key: Some("plain-api-key".to_string()), + password: Some("plain-password".to_string()), + username: Some("alice".to_string()), + ..EmbyClientSettings::default() + }; + let first = client::("key", "http://127.0.0.1:1", &settings); + let other = + client::("key", "http://127.0.0.1:1", &EmbyClientSettings { token: None, ..settings.clone() }); + let key = |client: &EmbyFamilyCatalogClient| EmbyConnectionCacheKey::new(Emby::KIND, &client.config); + let debug = format!("{:?}", key(&first)); + + assert!(!debug.contains("plain-"), "{debug}"); + assert_ne!(key(&first), key(&other)); + assert_eq!(key(&first), key(&client::("key", "http://127.0.0.1:1", &settings))); + } + + #[tokio::test] + async fn idle_empty_connection_cells_are_pruned_but_sessions_and_held_cells_stay() { + let key = |name: &str| EmbyConnectionCacheKey { + kind: crate::MediaServerKind::Jellyfin, + input_name: Arc::from(name), + base_url: Arc::from("http://127.0.0.1:1/"), + token: None, + api_key: None, + user_id: None, + username: None, + password: None, + }; + let held = shared_connection(&key("prune-held")); + drop(shared_connection(&key("prune-idle-empty"))); + let with_session = shared_connection(&key("prune-idle-session")); + *with_session.lock().await = Some(EmbyConnectionState { + status: MediaServerStatus { + kind: crate::MediaServerKind::Jellyfin, + server_id: Arc::from("server"), + display_name: None, + version: None, + owned: None, + }, + token: Arc::from("token"), + user_id: None, + origin: TokenOrigin::Configured, + }); + drop(with_session); + + drop(shared_connection(&key("prune-trigger"))); + + let cache = EMBY_CONNECTION_CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + assert!(cache.contains_key(&key("prune-held"))); + assert!(!cache.contains_key(&key("prune-idle-empty"))); + assert!(cache.contains_key(&key("prune-idle-session"))); + drop(cache); + drop(held); + } +} diff --git a/backend/media-server/src/emby/dto.rs b/backend/media-server/src/emby/dto.rs index 3f295e6f5..d8ba67160 100644 --- a/backend/media-server/src/emby/dto.rs +++ b/backend/media-server/src/emby/dto.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] @@ -26,21 +26,100 @@ pub struct EmbyViewDto { pub id: String, pub name: Option, pub collection_type: Option, + #[serde(rename = "Type")] pub type_: Option, } +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "PascalCase")] +pub struct EmbyNameDto { + pub name: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "PascalCase")] +pub struct EmbyPersonDto { + pub name: Option, + #[serde(rename = "Type")] + pub type_: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "PascalCase")] +pub struct EmbyMediaStreamDto { + #[serde(rename = "Type")] + pub type_: Option, + pub codec: Option, + pub width: Option, + pub height: Option, + pub channels: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "PascalCase")] +pub struct EmbyVirtualFolderDto { + pub name: Option, + pub collection_type: Option, + pub item_id: Option, + pub id: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "PascalCase")] +pub struct EmbyUserDto { + pub id: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "PascalCase")] +pub struct EmbyAuthenticationResultDto { + pub access_token: Option, + pub user: Option, + pub server_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct EmbyAuthenticateByNameRequest<'a> { + pub username: &'a str, + pub pw: &'a str, +} + #[derive(Debug, Clone, Deserialize, PartialEq)] #[serde(rename_all = "PascalCase")] pub struct EmbyItemDto { pub id: String, pub name: Option, + #[serde(rename = "Type")] pub type_: Option, + pub original_title: Option, + pub sort_name: Option, + pub overview: Option, + #[serde(default)] + pub taglines: Vec, + #[serde(default)] + pub genres: Vec, + #[serde(default)] + pub studios: Vec, + #[serde(default)] + pub people: Vec, + pub official_rating: Option, + pub community_rating: Option, + pub premiere_date: Option, + pub date_created: Option, + #[serde(default)] + pub production_locations: Vec, pub production_year: Option, pub parent_id: Option, pub series_id: Option, pub series_name: Option, + pub season_id: Option, pub parent_index_number: Option, pub index_number: Option, + pub child_count: Option, + pub recursive_item_count: Option, + pub run_time_ticks: Option, + pub location_type: Option, #[serde(default)] pub provider_ids: HashMap, #[serde(default)] @@ -58,22 +137,24 @@ pub struct EmbyMediaSourceDto { pub id: Option, pub container: Option, pub path: Option, + pub protocol: Option, + pub is_remote: Option, + pub bitrate: Option, + pub run_time_ticks: Option, pub supports_direct_play: Option, pub supports_direct_stream: Option, -} - -#[derive(Debug, Clone, Deserialize, PartialEq)] -#[serde(rename_all = "PascalCase")] -pub struct EmbyPlaybackInfoDto { #[serde(default)] - pub media_sources: Vec, - pub play_session_id: Option, + pub media_streams: Vec, } #[cfg(test)] mod tests { use super::*; - use crate::test_fixtures::EMBY_ITEMS_PAGE_JSON; + use crate::test_fixtures::{ + EMBY_AUTH_RESULT_JSON, EMBY_EPISODES_PAGE_JSON, EMBY_ITEMS_PAGE_JSON, EMBY_MOVIES_PAGE_JSON, + EMBY_PUBLIC_SYSTEM_INFO_JSON, EMBY_SEASONS_PAGE_JSON, EMBY_SERIES_PAGE_JSON, EMBY_VIRTUAL_FOLDERS_JSON, + JELLYFIN_VIEWS_JSON, + }; #[test] fn parses_emby_item_page_and_keeps_edge_only_fields_at_boundary() { @@ -85,4 +166,135 @@ mod tests { assert!(page.items[0].path.as_deref().is_some_and(|path| path.contains("/redacted/"))); assert!(page.items[0].user_data.is_some()); } + + #[test] + fn parses_movie_page_with_media_streams() { + let page: EmbyItemsPageDto = serde_json::from_str(EMBY_MOVIES_PAGE_JSON).expect("fixture parses"); + + assert_eq!(page.total_record_count, 2); + let movie = &page.items[0]; + assert_eq!(movie.type_.as_deref(), Some("Movie")); + assert_eq!(movie.original_title.as_deref(), Some("Movie Redacted Original")); + assert_eq!(movie.sort_name.as_deref(), Some("movie redacted")); + assert_eq!(movie.taglines, vec!["Redacted tagline"]); + assert_eq!(movie.genres, vec!["Action", "Drama"]); + assert_eq!(movie.studios[0].name.as_deref(), Some("Studio Redacted")); + assert_eq!(movie.people.len(), 2); + assert_eq!(movie.people[0].name.as_deref(), Some("Actor Redacted")); + assert_eq!(movie.people[0].type_.as_deref(), Some("Actor")); + assert_eq!(movie.people[1].type_.as_deref(), Some("Director")); + assert_eq!(movie.official_rating.as_deref(), Some("PG-13")); + assert_eq!(movie.community_rating, Some(7.4)); + assert_eq!(movie.premiere_date.as_deref(), Some("2024-03-01T00:00:00.0000000Z")); + assert_eq!(movie.date_created.as_deref(), Some("2024-04-02T10:15:30.1234567Z")); + assert_eq!(movie.production_locations, vec!["Redacted Country"]); + assert_eq!(movie.run_time_ticks, Some(72_000_000_000)); + assert_eq!(movie.location_type.as_deref(), Some("FileSystem")); + assert_eq!(movie.image_tags.get("Primary").map(String::as_str), Some("image-tag-redacted")); + + let source = &movie.media_sources[0]; + assert_eq!(source.protocol.as_deref(), Some("File")); + assert_eq!(source.is_remote, Some(false)); + assert_eq!(source.bitrate, Some(8_000_000)); + assert_eq!(source.run_time_ticks, Some(72_000_000_000)); + assert_eq!(source.media_streams.len(), 3); + assert_eq!(source.media_streams[0].type_.as_deref(), Some("Video")); + assert_eq!(source.media_streams[0].codec.as_deref(), Some("h264")); + assert_eq!(source.media_streams[0].width, Some(1920)); + assert_eq!(source.media_streams[0].height, Some(1080)); + assert_eq!(source.media_streams[1].type_.as_deref(), Some("Audio")); + assert_eq!(source.media_streams[1].channels, Some(6)); + + let minimal = &page.items[1]; + assert_eq!(minimal.media_sources, Vec::new()); + assert_eq!(minimal.genres, Vec::::new()); + assert_eq!(minimal.people, Vec::new()); + assert!(minimal.community_rating.is_none()); + } + + #[test] + fn parses_series_and_episode_hierarchy_fields() { + let series: EmbyItemsPageDto = + serde_json::from_str(EMBY_SERIES_PAGE_JSON).expect("fixture parses"); + assert_eq!(series.items[0].child_count, Some(2)); + assert_eq!(series.items[0].recursive_item_count, Some(14)); + + let episodes: EmbyItemsPageDto = + serde_json::from_str(EMBY_EPISODES_PAGE_JSON).expect("fixture parses"); + assert_eq!(episodes.items.len(), 3); + let first = &episodes.items[0]; + assert_eq!(first.series_id.as_deref(), Some("series-redacted-1")); + assert_eq!(first.series_name.as_deref(), Some("Series Redacted")); + assert_eq!(first.season_id.as_deref(), Some("season-redacted-1")); + assert_eq!(first.index_number, Some(1)); + assert_eq!(first.parent_index_number, Some(1)); + assert_eq!(episodes.items[1].location_type.as_deref(), Some("Virtual")); + let remote = &episodes.items[2].media_sources[0]; + assert_eq!(remote.protocol.as_deref(), Some("Http")); + assert_eq!(remote.is_remote, Some(true)); + } + + #[test] + fn parses_type_field_as_pascal_type() { + let item: EmbyItemDto = serde_json::from_str(r#"{"Id":"i","Type":"Episode"}"#).expect("item parses"); + assert_eq!(item.type_.as_deref(), Some("Episode")); + + let page: EmbyItemsPageDto = serde_json::from_str(JELLYFIN_VIEWS_JSON).expect("fixture parses"); + assert_eq!(page.items[0].type_.as_deref(), Some("CollectionFolder")); + + let person: EmbyPersonDto = serde_json::from_str(r#"{"Name":"n","Type":"Actor"}"#).expect("person parses"); + assert_eq!(person.type_.as_deref(), Some("Actor")); + let stream: EmbyMediaStreamDto = serde_json::from_str(r#"{"Type":"Audio"}"#).expect("stream parses"); + assert_eq!(stream.type_.as_deref(), Some("Audio")); + } + + #[test] + fn parses_virtual_folders_with_item_id() { + let folders: Vec = + serde_json::from_str(EMBY_VIRTUAL_FOLDERS_JSON).expect("fixture parses"); + + assert_eq!(folders.len(), 4); + assert_eq!(folders[0].name.as_deref(), Some("Movies")); + assert_eq!(folders[0].collection_type.as_deref(), Some("movies")); + assert_eq!(folders[0].item_id.as_deref(), Some("library-redacted-movies")); + assert_eq!(folders[0].id.as_deref(), Some("library-redacted-movies-guid")); + assert_eq!(folders[1].collection_type.as_deref(), Some("tvshows")); + assert!(folders[2].collection_type.is_none()); + assert_eq!(folders[3].collection_type.as_deref(), Some("boxsets")); + } + + #[test] + fn parses_authentication_result() { + let auth: EmbyAuthenticationResultDto = serde_json::from_str(EMBY_AUTH_RESULT_JSON).expect("fixture parses"); + + assert_eq!(auth.access_token.as_deref(), Some("token-redacted")); + assert_eq!(auth.server_id.as_deref(), Some("server-redacted-id")); + assert_eq!(auth.user.and_then(|user| user.id).as_deref(), Some("user-redacted-id")); + } + + #[test] + fn parses_public_system_info() { + let info: EmbyPublicSystemInfoDto = serde_json::from_str(EMBY_PUBLIC_SYSTEM_INFO_JSON).expect("fixture parses"); + + assert_eq!(info.id.as_deref(), Some("server-redacted-id")); + assert_eq!(info.server_name.as_deref(), Some("Server Redacted")); + } + + #[test] + fn parses_seasons_page() { + let seasons: EmbyItemsPageDto = + serde_json::from_str(EMBY_SEASONS_PAGE_JSON).expect("fixture parses"); + + assert_eq!(seasons.items.len(), 2); + assert_eq!(seasons.items[0].type_.as_deref(), Some("Season")); + assert_eq!(seasons.items[0].index_number, Some(1)); + assert_eq!(seasons.items[0].child_count, Some(8)); + } + + #[test] + fn authenticate_request_serializes_username_and_pw() { + let body = serde_json::to_string(&EmbyAuthenticateByNameRequest { username: "u", pw: "p" }) + .expect("request serializes"); + assert_eq!(body, r#"{"Username":"u","Pw":"p"}"#); + } } diff --git a/backend/media-server/src/emby/flavor.rs b/backend/media-server/src/emby/flavor.rs new file mode 100644 index 000000000..939c687cb --- /dev/null +++ b/backend/media-server/src/emby/flavor.rs @@ -0,0 +1,336 @@ +use crate::{MediaServerError, MediaServerErrorKind, MediaServerImageRef, MediaServerKind, MediaServerStreamRef}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use shared::utils::short_hash; +use std::sync::Arc; + +const CLIENT_NAME: &str = "Tuliprox"; + +/// How this client introduces itself to an Emby-family server. The `DeviceId` +/// is derived from the input so the server replaces the previous session on a +/// restart instead of accumulating devices. +#[derive(Debug, Clone)] +pub struct EmbyClientIdentity { + device_id: Arc, +} + +impl EmbyClientIdentity { + pub(crate) fn new(input_name: &str, username: Option<&str>) -> Self { + let device_id = + format!("tuliprox-{}", short_hash(&format!("{input_name}\u{1f}{}", username.unwrap_or_default()))); + Self { device_id: Arc::from(device_id) } + } + + /// `MediaBrowser Client=..., Device=..., DeviceId=..., Version=...[, Token=...]`. + /// `token` must already have passed [`validate_token`]. + pub(crate) fn media_browser_value(&self, token: Option<&str>) -> String { + let mut value = format!( + "MediaBrowser Client=\"{CLIENT_NAME}\", Device=\"{CLIENT_NAME}\", DeviceId=\"{}\", Version=\"{}\"", + self.device_id, + env!("CARGO_PKG_VERSION") + ); + if let Some(token) = token { + value.push_str(", Token=\""); + value.push_str(token); + value.push('"'); + } + value + } +} + +/// A token ends up inside a quoted header parameter, so anything that is not +/// visible ASCII, or could terminate or escape the quoting, is refused. +pub(crate) fn validate_token<'a>(token: &'a str, provider: &'static str) -> Result<&'a str, MediaServerError> { + if token.is_empty() || !token.bytes().all(|byte| byte.is_ascii_graphic() && byte != b'"' && byte != b'\\') { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerAuthDenied) + .provider(provider) + .detail("token contains characters that are not allowed in a request header")); + } + Ok(token) +} + +pub(crate) fn sensitive_header_value(text: &str, provider: &'static str) -> Result { + let mut value = HeaderValue::from_str(text).map_err(|_| { + MediaServerError::new(MediaServerErrorKind::MediaServerAuthDenied) + .provider(provider) + .detail("credentials contain invalid header characters") + })?; + value.set_sensitive(true); + Ok(value) +} + +/// Request path as at most three segments, appended to the configured base URL +/// (percent-encoded, base path prefix preserved) by the client. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EmbyPath<'a> { + segments: [&'a str; 3], + len: usize, +} + +impl<'a> EmbyPath<'a> { + pub const fn one(first: &'a str) -> Self { Self { segments: [first, "", ""], len: 1 } } + + pub const fn three(first: &'a str, second: &'a str, third: &'a str) -> Self { + Self { segments: [first, second, third], len: 3 } + } + + pub fn segments(&self) -> &[&'a str] { &self.segments[..self.len] } +} + +/// How an endpoint is scoped to the authenticated user. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UserScope { + /// The endpoint is not user scoped. + None, + /// The user id is a segment of the endpoint path. + InPath, + /// The user id is sent as the `UserId` query parameter. + Query, +} + +/// An endpoint path together with the way it is scoped to the user. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EmbyEndpoint<'a> { + pub path: EmbyPath<'a>, + pub user_scope: UserScope, +} + +/// Borrowed view of an Emby-family stream reference. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EmbyItemRefParts<'a> { + pub input_name: &'a str, + pub server_id: &'a str, + pub item_id: &'a str, + pub media_source_id: Option<&'a str>, +} + +/// Borrowed view of an Emby-family image reference. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EmbyImageRefParts<'a> { + pub input_name: &'a str, + pub server_id: &'a str, + pub item_id: &'a str, + pub image_kind: &'a str, + pub tag: Option<&'a str>, +} + +/// Wire differences between Emby and Jellyfin, selected at compile time so the +/// mapper and client stay generic without dynamic dispatch. +pub trait EmbyFamilyFlavor: sealed::Sealed + Send + Sync + 'static { + const KIND: MediaServerKind; + const PROVIDER: &'static str; + + fn stream_ref( + input_name: &Arc, + server_id: &Arc, + item_id: &Arc, + media_source_id: Option>, + ) -> MediaServerStreamRef; + + fn image_ref( + input_name: &Arc, + server_id: &Arc, + item_id: &Arc, + image_kind: Arc, + tag: Option>, + ) -> MediaServerImageRef; + + /// `None` when the reference belongs to a different media-server kind. + fn stream_ref_parts(stream_ref: &MediaServerStreamRef) -> Option>; + + /// `None` when the reference belongs to a different media-server kind. + fn image_ref_parts(image_ref: &MediaServerImageRef) -> Option>; + + /// Identity and credential headers for one request. `token: None` yields the + /// identity-only form used for discovery and login. + fn auth_headers(identity: &EmbyClientIdentity, token: Option<&str>) -> Result; + + /// Item listing endpoint and how it is scoped to the user. + fn items_path(user_id: Option<&str>) -> EmbyEndpoint<'_>; + + /// Endpoint listing the libraries visible to a user and how it is scoped to the user. + fn user_views_path(user_id: &str) -> EmbyEndpoint<'_>; +} + +pub(crate) mod sealed { + /// Keeps [`EmbyFamilyFlavor`](super::EmbyFamilyFlavor) implementable only inside this crate. + pub trait Sealed {} +} + +/// Implements [`EmbyFamilyFlavor`] for a unit struct whose name equals both the +/// `MediaServerKind` variant and the `MediaServerStreamRef`/`MediaServerImageRef` variant. +macro_rules! impl_emby_family_flavor { + ($flavor:ident, $provider:literal, { $($flavor_items:item)* }) => { + impl $crate::emby::flavor::sealed::Sealed for $flavor {} + + impl $crate::emby::flavor::EmbyFamilyFlavor for $flavor { + const KIND: $crate::MediaServerKind = $crate::MediaServerKind::$flavor; + const PROVIDER: &'static str = $provider; + + fn stream_ref( + input_name: &::std::sync::Arc, + server_id: &::std::sync::Arc, + item_id: &::std::sync::Arc, + media_source_id: Option<::std::sync::Arc>, + ) -> $crate::MediaServerStreamRef { + $crate::MediaServerStreamRef::$flavor { + input_name: ::std::sync::Arc::clone(input_name), + server_id: ::std::sync::Arc::clone(server_id), + item_id: ::std::sync::Arc::clone(item_id), + media_source_id, + } + } + + fn image_ref( + input_name: &::std::sync::Arc, + server_id: &::std::sync::Arc, + item_id: &::std::sync::Arc, + image_kind: ::std::sync::Arc, + tag: Option<::std::sync::Arc>, + ) -> $crate::MediaServerImageRef { + $crate::MediaServerImageRef::$flavor { + input_name: ::std::sync::Arc::clone(input_name), + server_id: ::std::sync::Arc::clone(server_id), + item_id: ::std::sync::Arc::clone(item_id), + image_kind, + tag, + } + } + + fn stream_ref_parts( + stream_ref: &$crate::MediaServerStreamRef, + ) -> Option<$crate::emby::flavor::EmbyItemRefParts<'_>> { + match stream_ref { + $crate::MediaServerStreamRef::$flavor { input_name, server_id, item_id, media_source_id } => { + Some($crate::emby::flavor::EmbyItemRefParts { + input_name, + server_id, + item_id, + media_source_id: media_source_id.as_deref(), + }) + } + _ => None, + } + } + + fn image_ref_parts( + image_ref: &$crate::MediaServerImageRef, + ) -> Option<$crate::emby::flavor::EmbyImageRefParts<'_>> { + match image_ref { + $crate::MediaServerImageRef::$flavor { input_name, server_id, item_id, image_kind, tag } => { + Some($crate::emby::flavor::EmbyImageRefParts { + input_name, + server_id, + item_id, + image_kind, + tag: tag.as_deref(), + }) + } + _ => None, + } + } + + $($flavor_items)* + } + }; +} + +pub(crate) use impl_emby_family_flavor; + +const X_EMBY_TOKEN: HeaderName = HeaderName::from_static("x-emby-token"); +const X_EMBY_AUTHORIZATION: HeaderName = HeaderName::from_static("x-emby-authorization"); + +#[derive(Debug, Clone, Copy)] +pub struct Emby; + +impl_emby_family_flavor!(Emby, "emby", { + fn auth_headers(identity: &EmbyClientIdentity, token: Option<&str>) -> Result { + let mut headers = HeaderMap::with_capacity(2); + headers.insert(X_EMBY_AUTHORIZATION, sensitive_header_value(&identity.media_browser_value(None), "emby")?); + if let Some(token) = token { + headers.insert(X_EMBY_TOKEN, sensitive_header_value(validate_token(token, "emby")?, "emby")?); + } + Ok(headers) + } + + fn items_path(user_id: Option<&str>) -> EmbyEndpoint<'_> { + match user_id { + Some(user_id) => { + EmbyEndpoint { path: EmbyPath::three("Users", user_id, "Items"), user_scope: UserScope::InPath } + } + None => EmbyEndpoint { path: EmbyPath::one("Items"), user_scope: UserScope::None }, + } + } + + fn user_views_path(user_id: &str) -> EmbyEndpoint<'_> { + EmbyEndpoint { path: EmbyPath::three("Users", user_id, "Views"), user_scope: UserScope::InPath } + } +}); + +#[cfg(test)] +mod tests { + use super::*; + use crate::MediaServerErrorKind; + + fn identity() -> EmbyClientIdentity { EmbyClientIdentity::new("input", Some("alice")) } + + fn expected_identity() -> String { + format!( + "MediaBrowser Client=\"Tuliprox\", Device=\"Tuliprox\", DeviceId=\"tuliprox-{}\", Version=\"{}\"", + short_hash("input\u{1f}alice"), + env!("CARGO_PKG_VERSION") + ) + } + + #[test] + fn auth_headers_send_token_and_identity_in_emby_headers() { + let headers = Emby::auth_headers(&identity(), Some("secret")).expect("headers build"); + + assert_eq!(headers.len(), 2); + assert_eq!(headers.get("x-emby-token").expect("token header").to_str().expect("ascii"), "secret"); + assert_eq!( + headers.get("x-emby-authorization").expect("identity header").to_str().expect("ascii"), + expected_identity() + ); + assert!(headers.get("x-emby-token").expect("token header").is_sensitive()); + assert!(headers.get(reqwest::header::AUTHORIZATION).is_none()); + } + + #[test] + fn identity_only_headers_carry_no_token() { + let headers = Emby::auth_headers(&identity(), None).expect("headers build"); + + assert_eq!(headers.len(), 1); + assert!(headers.get("x-emby-token").is_none()); + } + + #[test] + fn tokens_that_cannot_be_quoted_are_auth_denied() { + for token in ["a\"b", "a b", "a\r\nb", "tök\u{e9}n"] { + let error = Emby::auth_headers(&identity(), Some(token)).expect_err("token is rejected"); + + assert_eq!(error.kind, MediaServerErrorKind::MediaServerAuthDenied, "token {token:?}"); + } + } + + #[test] + fn paths_scope_to_the_user_with_a_path_prefix() { + assert_eq!(Emby::items_path(None), EmbyEndpoint { path: EmbyPath::one("Items"), user_scope: UserScope::None }); + assert_eq!( + Emby::items_path(Some("u1")), + EmbyEndpoint { path: EmbyPath::three("Users", "u1", "Items"), user_scope: UserScope::InPath } + ); + assert_eq!( + Emby::user_views_path("u1"), + EmbyEndpoint { path: EmbyPath::three("Users", "u1", "Views"), user_scope: UserScope::InPath } + ); + } + + #[test] + fn device_id_is_stable_per_input_and_user() { + let first = EmbyClientIdentity::new("input", Some("alice")).media_browser_value(None); + + assert_eq!(first, EmbyClientIdentity::new("input", Some("alice")).media_browser_value(None)); + assert_ne!(first, EmbyClientIdentity::new("input", Some("bob")).media_browser_value(None)); + assert_ne!(first, EmbyClientIdentity::new("other", Some("alice")).media_browser_value(None)); + } +} diff --git a/backend/media-server/src/emby/mapper.rs b/backend/media-server/src/emby/mapper.rs new file mode 100644 index 000000000..0697dcea2 --- /dev/null +++ b/backend/media-server/src/emby/mapper.rs @@ -0,0 +1,775 @@ +use crate::{ + emby::{ + dto::{EmbyItemDto, EmbyMediaSourceDto, EmbyMediaStreamDto, EmbyPersonDto, EmbyViewDto, EmbyVirtualFolderDto}, + flavor::EmbyFamilyFlavor, + }, + library_selection::MediaServerLibraryCandidate, + non_blank_arc, MediaServerAudioTechnicalFacts, MediaServerDescriptiveFacts, MediaServerEpisode, + MediaServerImageRef, MediaServerMovie, MediaServerProviderIdHint, MediaServerSeason, MediaServerSeries, + MediaServerStreamRef, MediaServerTechnicalFacts, MediaServerVideoTechnicalFacts, +}; +use chrono::{DateTime, NaiveDate}; +use shared::model::MediaServerLibraryKind; +use std::{ + collections::HashMap, + sync::{Arc, LazyLock}, +}; + +const TICKS_PER_SECOND: u64 = 10_000_000; +const PRIMARY_IMAGE_KIND: &str = "Primary"; +/// Shared by every image reference, so mapping a large library only bumps a refcount. +static PRIMARY_IMAGE_KIND_ARC: LazyLock> = LazyLock::new(|| Arc::from(PRIMARY_IMAGE_KIND)); + +/// Identity of the library a page of items was requested from. +pub struct EmbyMapContext<'a> { + pub input_name: &'a Arc, + pub server_id: &'a Arc, + pub library_id: &'a Arc, + /// When `false` the item list was requested without `MediaSources`, so the + /// stream reference falls back to the server's default source. + pub include_media_sources: bool, +} + +pub fn emby_item_to_movie( + ctx: &EmbyMapContext<'_>, + item: &EmbyItemDto, +) -> Option { + let base = ItemBase::new::(ctx, item, "Movie")?; + let source = chosen_source(ctx, item); + Some(MediaServerMovie { + input_name: Arc::clone(ctx.input_name), + server_id: Arc::clone(ctx.server_id), + library_id: Arc::clone(ctx.library_id), + stream_ref: stream_ref::(ctx, &base.item_id, source), + item_id: base.item_id, + title: base.title, + year: item.production_year, + release_date: base.release_date, + source_version_hint: base.source_version_hint, + provider_hints: base.provider_hints, + descriptive_facts: descriptive_facts(item, false), + technical_facts: technical_facts(source, item.run_time_ticks), + image_ref: base.image_ref, + }) +} + +pub fn emby_item_to_series( + ctx: &EmbyMapContext<'_>, + item: &EmbyItemDto, +) -> Option { + let base = ItemBase::new::(ctx, item, "Series")?; + Some(MediaServerSeries { + input_name: Arc::clone(ctx.input_name), + server_id: Arc::clone(ctx.server_id), + library_id: Arc::clone(ctx.library_id), + item_id: base.item_id, + title: base.title, + year: item.production_year, + release_date: base.release_date, + source_version_hint: base.source_version_hint, + provider_hints: base.provider_hints, + descriptive_facts: descriptive_facts(item, true), + child_count: item.child_count, + episode_count: item.recursive_item_count, + image_ref: base.image_ref, + }) +} + +pub fn emby_item_to_season( + ctx: &EmbyMapContext<'_>, + item: &EmbyItemDto, +) -> Option { + let base = ItemBase::new::(ctx, item, "Season")?; + Some(MediaServerSeason { + input_name: Arc::clone(ctx.input_name), + server_id: Arc::clone(ctx.server_id), + library_id: Arc::clone(ctx.library_id), + item_id: base.item_id, + series_id: non_blank_arc(item.series_id.as_deref()), + series_title: non_blank_arc(item.series_name.as_deref()), + title: base.title, + season: item.index_number, + year: item.production_year, + release_date: base.release_date, + source_version_hint: base.source_version_hint, + provider_hints: base.provider_hints, + descriptive_facts: descriptive_facts(item, false), + episode_count: item.child_count, + image_ref: base.image_ref, + }) +} + +pub fn emby_item_to_episode( + ctx: &EmbyMapContext<'_>, + item: &EmbyItemDto, +) -> Option { + let base = ItemBase::new::(ctx, item, "Episode")?; + let source = chosen_source(ctx, item); + Some(MediaServerEpisode { + input_name: Arc::clone(ctx.input_name), + server_id: Arc::clone(ctx.server_id), + library_id: Arc::clone(ctx.library_id), + stream_ref: stream_ref::(ctx, &base.item_id, source), + item_id: base.item_id, + series_id: non_blank_arc(item.series_id.as_deref()), + series_title: non_blank_arc(item.series_name.as_deref()), + title: base.title, + season: item.parent_index_number, + episode: item.index_number, + release_date: base.release_date, + source_version_hint: base.source_version_hint, + provider_hints: base.provider_hints, + descriptive_facts: descriptive_facts(item, false), + technical_facts: technical_facts(source, item.run_time_ticks), + image_ref: base.image_ref, + }) +} + +/// Missing episodes (`LocationType: Virtual`) have no media and must not become playable entries. +pub fn is_virtual_item(item: &EmbyItemDto) -> bool { + item.location_type.as_deref().is_some_and(|value| value.trim().eq_ignore_ascii_case("virtual")) +} + +impl MediaServerLibraryCandidate for EmbyVirtualFolderDto { + fn library_key(&self) -> Option<&str> { + [self.item_id.as_deref(), self.id.as_deref()].into_iter().flatten().find(|value| !value.trim().is_empty()) + } + + fn library_title(&self) -> Option<&str> { self.name.as_deref() } + + fn library_kind(&self) -> MediaServerLibraryKind { emby_library_kind(self.collection_type.as_deref()) } +} + +impl MediaServerLibraryCandidate for EmbyViewDto { + fn library_key(&self) -> Option<&str> { Some(self.id.as_str()) } + + fn library_title(&self) -> Option<&str> { self.name.as_deref() } + + fn library_kind(&self) -> MediaServerLibraryKind { emby_library_kind(self.collection_type.as_deref()) } +} + +pub(crate) fn emby_library_kind(collection_type: Option<&str>) -> MediaServerLibraryKind { + match collection_type.map(str::trim) { + Some(value) if value.eq_ignore_ascii_case("movies") => MediaServerLibraryKind::Movies, + Some(value) if value.eq_ignore_ascii_case("tvshows") => MediaServerLibraryKind::TvShows, + _ => MediaServerLibraryKind::Unsupported, + } +} + +/// Fields every item kind shares. +struct ItemBase { + item_id: Arc, + title: Arc, + release_date: Option>, + source_version_hint: Option>, + provider_hints: Vec, + image_ref: Option, +} + +impl ItemBase { + fn new(ctx: &EmbyMapContext<'_>, item: &EmbyItemDto, noun: &str) -> Option { + let item_id = non_blank_arc(Some(&item.id))?; + let title = non_blank_arc(item.name.as_deref()).unwrap_or_else(|| fallback_title::(noun)); + let image_ref = item.image_tags.get(PRIMARY_IMAGE_KIND).and_then(|tag| non_blank_arc(Some(tag))).map(|tag| { + F::image_ref(ctx.input_name, ctx.server_id, &item_id, Arc::clone(&PRIMARY_IMAGE_KIND_ARC), Some(tag)) + }); + Some(Self { + title, + release_date: release_date(item.premiere_date.as_deref()), + source_version_hint: source_version_hint(item.date_created.as_deref()), + provider_hints: provider_hints(&item.provider_ids), + image_ref, + item_id, + }) + } +} + +fn fallback_title(noun: &str) -> Arc { + let mut chars = F::PROVIDER.chars(); + let mut title = String::with_capacity(F::PROVIDER.len() + 1 + noun.len()); + title.extend(chars.next().map(|first| first.to_ascii_uppercase())); + title.push_str(chars.as_str()); + title.push(' '); + title.push_str(noun); + Arc::::from(title) +} + +fn release_date(premiere_date: Option<&str>) -> Option> { + let date = premiere_date?.trim().get(..10)?; + NaiveDate::parse_from_str(date, "%Y-%m-%d").ok().map(|_| Arc::::from(date)) +} + +/// Unix seconds of `DateCreated`, the same meaning as Plex `addedAt`. +fn source_version_hint(date_created: Option<&str>) -> Option> { + let seconds = DateTime::parse_from_rfc3339(date_created?.trim()).ok()?.timestamp(); + (seconds > 0).then(|| Arc::::from(seconds.to_string())) +} + +fn provider_hints(provider_ids: &HashMap) -> Vec { + let mut hints: Vec = provider_ids + .iter() + .filter_map(|(namespace, value)| { + let namespace = namespace.trim(); + let value = non_blank_arc(Some(value))?; + (!namespace.is_empty()).then(|| MediaServerProviderIdHint { + namespace: Arc::::from(namespace.to_ascii_lowercase()), + value, + }) + }) + .collect(); + hints.sort_by(|a, b| a.namespace.cmp(&b.namespace).then_with(|| a.value.cmp(&b.value))); + hints +} + +fn descriptive_facts(item: &EmbyItemDto, studio_is_network: bool) -> Option { + let first_studio = item.studios.iter().find_map(|studio| non_blank_arc(studio.name.as_deref())); + let PeopleByRole { directors, writers, cast } = partition_people(&item.people); + let (studio, network) = if studio_is_network { (None, first_studio) } else { (first_studio, None) }; + let facts = MediaServerDescriptiveFacts { + original_title: non_blank_arc(item.original_title.as_deref()), + sort_title: non_blank_arc(item.sort_name.as_deref()), + summary: non_blank_arc(item.overview.as_deref()), + tagline: item.taglines.iter().find_map(|tagline| non_blank_arc(Some(tagline))), + studio, + network, + content_rating: non_blank_arc(item.official_rating.as_deref()), + audience_rating: item.community_rating.map(|rating| Arc::::from(format!("{rating:.1}"))), + genres: item.genres.iter().filter_map(|genre| non_blank_arc(Some(genre))).collect(), + countries: item.production_locations.iter().filter_map(|country| non_blank_arc(Some(country))).collect(), + directors, + writers, + cast, + ..MediaServerDescriptiveFacts::default() + }; + (!facts.is_empty()).then_some(facts) +} + +#[derive(Default)] +struct PeopleByRole { + directors: Vec>, + writers: Vec>, + cast: Vec>, +} + +/// Directors, writers and cast in one pass over `People`; other roles are dropped. +fn partition_people(people: &[EmbyPersonDto]) -> PeopleByRole { + let mut roles = PeopleByRole::default(); + for person in people { + let Some(kind) = person.type_.as_deref().map(str::trim) else { continue }; + let target = if kind.eq_ignore_ascii_case("Director") { + &mut roles.directors + } else if kind.eq_ignore_ascii_case("Writer") { + &mut roles.writers + } else if kind.eq_ignore_ascii_case("Actor") || kind.eq_ignore_ascii_case("GuestStar") { + &mut roles.cast + } else { + continue; + }; + target.extend(non_blank_arc(person.name.as_deref())); + } + roles +} + +/// A source Tuliprox can proxy: a local file, never a remote/`.strm` entry that could loop back into Tuliprox. +fn is_eligible_source(source: &EmbyMediaSourceDto) -> bool { + source.is_remote != Some(true) + && source + .protocol + .as_deref() + .map(str::trim) + .is_none_or(|protocol| protocol.is_empty() || protocol.eq_ignore_ascii_case("file")) +} + +fn supports_direct_access(source: &EmbyMediaSourceDto) -> bool { + source.supports_direct_play == Some(true) || source.supports_direct_stream == Some(true) +} + +fn choose_source(sources: &[EmbyMediaSourceDto]) -> Option<&EmbyMediaSourceDto> { + let mut eligible = sources.iter().filter(|source| is_eligible_source(source)); + let first = eligible.next()?; + if supports_direct_access(first) { + return Some(first); + } + Some(eligible.find(|source| supports_direct_access(source)).unwrap_or(first)) +} + +/// The source technical facts are taken from; `None` when sources were not requested. +fn chosen_source<'a>(ctx: &EmbyMapContext<'_>, item: &'a EmbyItemDto) -> Option<&'a EmbyMediaSourceDto> { + if ctx.include_media_sources { + choose_source(&item.media_sources) + } else { + None + } +} + +fn stream_ref( + ctx: &EmbyMapContext<'_>, + item_id: &Arc, + source: Option<&EmbyMediaSourceDto>, +) -> Option { + let media_source_id = if ctx.include_media_sources { non_blank_arc(source?.id.as_deref()) } else { None }; + Some(F::stream_ref(ctx.input_name, ctx.server_id, item_id, media_source_id)) +} + +fn technical_facts(source: Option<&EmbyMediaSourceDto>, item_ticks: Option) -> Option { + let streams = source.map_or(&[][..], |source| source.media_streams.as_slice()); + let video = first_stream(streams, "video").map(|stream| MediaServerVideoTechnicalFacts { + codec: non_blank_arc(stream.codec.as_deref()), + width: stream.width, + height: stream.height, + }); + let audio = first_stream(streams, "audio").map(|stream| MediaServerAudioTechnicalFacts { + codec: non_blank_arc(stream.codec.as_deref()), + channels: stream.channels, + }); + let facts = MediaServerTechnicalFacts { + container: source.and_then(|source| non_blank_arc(source.container.as_deref())), + duration_secs: ticks_to_secs(source.and_then(|source| source.run_time_ticks).or(item_ticks)), + bitrate: source.and_then(|source| source.bitrate).filter(|bitrate| *bitrate > 0), + video: video.filter(|video| !video.is_empty()), + audio: audio.filter(|audio| !audio.is_empty()), + }; + (!facts.is_empty()).then_some(facts) +} + +fn first_stream<'a>(streams: &'a [EmbyMediaStreamDto], stream_type: &str) -> Option<&'a EmbyMediaStreamDto> { + streams + .iter() + .find(|stream| stream.type_.as_deref().is_some_and(|value| value.trim().eq_ignore_ascii_case(stream_type))) +} + +fn ticks_to_secs(ticks: Option) -> Option { + u32::try_from(ticks? / TICKS_PER_SECOND).ok().filter(|secs| *secs > 0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + emby::{dto::EmbyItemsPageDto, flavor::Emby}, + jellyfin::flavor::Jellyfin, + media_server_image_ref_to_internal_url, media_server_stream_ref_to_internal_url, parse_media_server_image_ref, + parse_media_server_stream_ref, + test_fixtures::{ + EMBY_EPISODES_PAGE_JSON, EMBY_MOVIES_PAGE_JSON, EMBY_SEASONS_PAGE_JSON, EMBY_SERIES_PAGE_JSON, + EMBY_VIRTUAL_FOLDERS_JSON, JELLYFIN_VIEWS_JSON, + }, + MediaServerKind, + }; + use shared::model::{MediaServerLibraryKind, MediaServerLibrarySelector}; + + fn input_name() -> Arc { Arc::::from("media_server") } + fn server_id() -> Arc { Arc::::from("server-redacted-id") } + fn library_id() -> Arc { Arc::::from("library-redacted") } + + fn items(json: &str) -> Vec { + serde_json::from_str::>(json).expect("fixture parses").items + } + + fn bare_item(id: &str) -> EmbyItemDto { + serde_json::from_value(serde_json::json!({ "Id": id })).expect("minimal item parses") + } + + fn with_ctx(include_media_sources: bool, run: impl FnOnce(&EmbyMapContext<'_>) -> R) -> R { + let (input_name, server_id, library_id) = (input_name(), server_id(), library_id()); + run(&EmbyMapContext { + input_name: &input_name, + server_id: &server_id, + library_id: &library_id, + include_media_sources, + }) + } + + fn strs(values: &[Arc]) -> Vec<&str> { values.iter().map(AsRef::as_ref).collect() } + + fn check_movie_mapping() { + let movie = + with_ctx(true, |ctx| emby_item_to_movie::(ctx, &items(EMBY_MOVIES_PAGE_JSON)[0])).expect("movie maps"); + + assert_eq!(movie.item_id.as_ref(), "movie-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-03-01")); + assert_eq!(movie.source_version_hint.as_deref(), Some("1712052930")); + assert_eq!( + movie.provider_hints.iter().map(|h| (h.namespace.as_ref(), h.value.as_ref())).collect::>(), + vec![("imdb", "tt0000001"), ("tmdb", "12345")] + ); + + let descriptive = movie.descriptive_facts.as_ref().expect("descriptive facts"); + assert_eq!(descriptive.original_title.as_deref(), Some("Movie Redacted Original")); + assert_eq!(descriptive.sort_title.as_deref(), Some("movie redacted")); + assert_eq!(descriptive.summary.as_deref(), Some("Redacted overview text.")); + assert_eq!(descriptive.tagline.as_deref(), Some("Redacted tagline")); + assert_eq!(descriptive.studio.as_deref(), Some("Studio Redacted")); + assert!(descriptive.network.is_none()); + assert_eq!(descriptive.content_rating.as_deref(), Some("PG-13")); + assert_eq!(descriptive.audience_rating.as_deref(), Some("7.4")); + assert_eq!(strs(&descriptive.genres), vec!["Action", "Drama"]); + assert_eq!(strs(&descriptive.countries), vec!["Redacted Country"]); + assert_eq!(strs(&descriptive.directors), vec!["Director Redacted"]); + assert_eq!(strs(&descriptive.cast), vec!["Actor Redacted"]); + assert_eq!(descriptive.writers, Vec::>::new()); + + 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)); + let video = technical.video.as_ref().expect("video"); + assert_eq!((video.codec.as_deref(), video.width, video.height), (Some("h264"), Some(1920), Some(1080))); + let audio = technical.audio.as_ref().expect("audio"); + assert_eq!((audio.codec.as_deref(), audio.channels), (Some("aac"), Some(6))); + + assert_eq!( + movie.stream_ref, + Some(F::stream_ref(&input_name(), &server_id(), &movie.item_id, Some("media-source-redacted-1".into()))) + ); + assert_eq!( + movie.image_ref, + Some(F::image_ref( + &input_name(), + &server_id(), + &movie.item_id, + "Primary".into(), + Some("image-tag-redacted".into()) + )) + ); + assert!(!format!("{movie:?}").contains("/redacted/upstream/path")); + } + + #[test] + fn maps_movie_with_descriptive_technical_and_refs() { + check_movie_mapping::(); + check_movie_mapping::(); + let movie = with_ctx(true, |ctx| emby_item_to_movie::(ctx, &items(EMBY_MOVIES_PAGE_JSON)[0])).unwrap(); + assert!(matches!(movie.stream_ref, Some(MediaServerStreamRef::Emby { .. }))); + assert!(matches!(movie.image_ref, Some(MediaServerImageRef::Emby { .. }))); + } + + #[test] + fn jellyfin_flavor_produces_jellyfin_refs_and_round_trips_internal_url() { + let movie = with_ctx(true, |ctx| emby_item_to_movie::(ctx, &items(EMBY_MOVIES_PAGE_JSON)[0])) + .expect("movie maps"); + let stream_ref = movie.stream_ref.expect("stream ref"); + let image_ref = movie.image_ref.expect("image ref"); + assert!(matches!(stream_ref, MediaServerStreamRef::Jellyfin { .. })); + assert!(matches!(image_ref, MediaServerImageRef::Jellyfin { .. })); + + let stream_url = media_server_stream_ref_to_internal_url(&stream_ref); + assert!(stream_url.starts_with("media-server://jellyfin/")); + assert_eq!(parse_media_server_stream_ref(&input_name(), &stream_url).expect("parses"), stream_ref); + let image_url = media_server_image_ref_to_internal_url(&image_ref); + assert!(image_url.starts_with("media-server://image/jellyfin/")); + assert_eq!(parse_media_server_image_ref(&image_url).expect("parses"), image_ref); + + let emby = with_ctx(true, |ctx| emby_item_to_movie::(ctx, &items(EMBY_MOVIES_PAGE_JSON)[0])).unwrap(); + let emby_url = media_server_stream_ref_to_internal_url(&emby.stream_ref.expect("stream ref")); + assert!(emby_url.starts_with("media-server://emby/")); + assert_eq!(Jellyfin::stream_ref_parts(&stream_ref).expect("parts").item_id, "movie-redacted-1"); + assert!(Emby::stream_ref_parts(&stream_ref).is_none()); + assert!(Emby::image_ref_parts(&image_ref).is_none()); + let image_parts = Jellyfin::image_ref_parts(&image_ref).expect("image parts"); + assert_eq!((image_parts.image_kind, image_parts.tag), ("Primary", Some("image-tag-redacted"))); + } + + fn check_series_season_episode() { + with_ctx(true, |ctx| { + let series = emby_item_to_series::(ctx, &items(EMBY_SERIES_PAGE_JSON)[0]).expect("series maps"); + assert_eq!(series.item_id.as_ref(), "series-redacted-1"); + assert_eq!(series.title.as_ref(), "Series Redacted"); + assert_eq!(series.year, Some(2020)); + assert_eq!(series.release_date.as_deref(), Some("2020-01-10")); + assert_eq!(series.child_count, Some(2)); + assert_eq!(series.episode_count, Some(14)); + let facts = series.descriptive_facts.as_ref().expect("series facts"); + assert_eq!(facts.network.as_deref(), Some("Network Redacted")); + assert!(facts.studio.is_none()); + assert_eq!(facts.audience_rating.as_deref(), Some("8.1")); + assert_eq!( + series.provider_hints.iter().map(|h| h.namespace.as_ref()).collect::>(), + vec!["tmdb", "tvdb"] + ); + assert!(series.image_ref.is_some()); + + let seasons = items(EMBY_SEASONS_PAGE_JSON); + let season = emby_item_to_season::(ctx, &seasons[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("Series Redacted")); + assert_eq!(season.title.as_ref(), "Season 1"); + assert_eq!(season.season, Some(1)); + assert_eq!(season.episode_count, Some(8)); + assert_eq!(season.release_date.as_deref(), Some("2020-01-10")); + assert!(season.image_ref.is_some()); + let season_two = emby_item_to_season::(ctx, &seasons[1]).expect("season maps"); + assert_eq!(season_two.season, Some(2)); + assert!(season_two.image_ref.is_none()); + + let episode = emby_item_to_episode::(ctx, &items(EMBY_EPISODES_PAGE_JSON)[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("Series Redacted")); + assert_eq!(episode.season, Some(1)); + assert_eq!(episode.episode, Some(1)); + assert_eq!(episode.release_date.as_deref(), Some("2020-01-10")); + assert_eq!(episode.provider_hints.len(), 1); + let technical = episode.technical_facts.as_ref().expect("technical facts"); + assert_eq!(technical.duration_secs, Some(2700)); + assert_eq!(technical.bitrate, Some(4_000_000)); + assert_eq!( + episode.stream_ref, + Some(F::stream_ref( + &input_name(), + &server_id(), + &episode.item_id, + Some("media-source-redacted-2".into()) + )) + ); + }); + } + + #[test] + fn maps_series_season_episode_links() { + check_series_season_episode::(); + check_series_season_episode::(); + } + + fn check_remote_source() { + with_ctx(true, |ctx| { + let episode = + emby_item_to_episode::(ctx, &items(EMBY_EPISODES_PAGE_JSON)[2]).expect("identity stays valid"); + assert!(episode.stream_ref.is_none()); + assert_eq!(episode.item_id.as_ref(), "episode-redacted-3"); + assert!(!format!("{episode:?}").contains("redacted.invalid")); + }); + } + + #[test] + fn remote_media_source_yields_no_stream_ref() { + check_remote_source::(); + check_remote_source::(); + } + + #[test] + fn media_source_choice_filters_ineligible_and_prefers_direct_play() { + let item: EmbyItemDto = serde_json::from_value(serde_json::json!({ + "Id": "item-1", + "MediaSources": [ + { "Id": "remote", "Protocol": "Http", "SupportsDirectPlay": true }, + { "Id": "flagged-remote", "IsRemote": true, "SupportsDirectPlay": true }, + { "Id": "no-direct", "Protocol": "file", "SupportsDirectPlay": false }, + { "Id": "direct", "SupportsDirectStream": true } + ] + })) + .unwrap(); + let movie = with_ctx(true, |ctx| emby_item_to_movie::(ctx, &item)).unwrap(); + assert_eq!(Emby::stream_ref_parts(&movie.stream_ref.unwrap()).unwrap().media_source_id, Some("direct")); + + let item: EmbyItemDto = serde_json::from_value(serde_json::json!({ + "Id": "item-2", + "MediaSources": [ + { "Id": "remote", "Protocol": "Http" }, + { "Id": "first-eligible", "Protocol": "File" }, + { "Id": "second-eligible" } + ] + })) + .unwrap(); + let movie = with_ctx(true, |ctx| emby_item_to_movie::(ctx, &item)).unwrap(); + assert_eq!(Emby::stream_ref_parts(&movie.stream_ref.unwrap()).unwrap().media_source_id, Some("first-eligible")); + } + + #[test] + fn requested_media_sources_missing_yields_no_stream_ref() { + let movie = with_ctx(true, |ctx| emby_item_to_movie::(ctx, &items(EMBY_MOVIES_PAGE_JSON)[1])).unwrap(); + assert!(movie.stream_ref.is_none()); + } + + #[test] + fn local_media_source_without_id_yields_ref_for_the_server_default_source() { + for source in [ + serde_json::json!({ "Protocol": "File", "SupportsDirectPlay": true }), + serde_json::json!({ "Id": " ", "Protocol": "File", "SupportsDirectPlay": true }), + ] { + let item: EmbyItemDto = + serde_json::from_value(serde_json::json!({ "Id": "item-1", "MediaSources": [source] })).unwrap(); + let movie = with_ctx(true, |ctx| emby_item_to_movie::(ctx, &item)).expect("movie maps"); + let parts = Emby::stream_ref_parts(movie.stream_ref.as_ref().expect("stream ref stays playable")) + .expect("emby ref"); + assert_eq!(parts.item_id, "item-1"); + assert_eq!(parts.media_source_id, None); + } + } + + fn check_include_media_sources_false() { + let movie = + with_ctx(false, |ctx| emby_item_to_movie::(ctx, &items(EMBY_MOVIES_PAGE_JSON)[1])).expect("movie maps"); + assert_eq!(movie.stream_ref, Some(F::stream_ref(&input_name(), &server_id(), &movie.item_id, None))); + } + + #[test] + fn include_media_sources_false_yields_ref_without_source_id() { + check_include_media_sources_false::(); + check_include_media_sources_false::(); + let movie = with_ctx(false, |ctx| emby_item_to_movie::(ctx, &items(EMBY_MOVIES_PAGE_JSON)[0])).unwrap(); + assert_eq!(Emby::stream_ref_parts(&movie.stream_ref.unwrap()).unwrap().media_source_id, None); + } + + #[test] + fn virtual_items_are_dropped_but_counted_upstream() { + let episodes = items(EMBY_EPISODES_PAGE_JSON); + assert!(!is_virtual_item(&episodes[0])); + assert!(is_virtual_item(&episodes[1])); + assert!(!is_virtual_item(&episodes[2])); + let mut item = bare_item("x"); + item.location_type = Some("virtual".to_string()); + assert!(is_virtual_item(&item)); + assert!(!is_virtual_item(&bare_item("y"))); + } + + fn check_minimal() { + with_ctx(true, |ctx| { + let item = bare_item("only-id"); + let capitalized = match F::KIND { + MediaServerKind::Emby => "Emby", + _ => "Jellyfin", + }; + let movie = emby_item_to_movie::(ctx, &item).expect("movie"); + assert_eq!(movie.title.as_ref(), format!("{capitalized} Movie")); + assert!(movie.year.is_none() && movie.release_date.is_none() && movie.source_version_hint.is_none()); + assert_eq!(movie.provider_hints, Vec::new()); + assert!(movie.descriptive_facts.is_none()); + assert!(movie.technical_facts.is_none()); + assert!(movie.stream_ref.is_none()); + assert!(movie.image_ref.is_none()); + + assert_eq!(emby_item_to_series::(ctx, &item).unwrap().title.as_ref(), format!("{capitalized} Series")); + assert_eq!(emby_item_to_season::(ctx, &item).unwrap().title.as_ref(), format!("{capitalized} Season")); + let episode = emby_item_to_episode::(ctx, &item).unwrap(); + assert_eq!(episode.title.as_ref(), format!("{capitalized} Episode")); + assert!(episode.series_id.is_none() && episode.season.is_none() && episode.episode.is_none()); + + let mut blank_name = bare_item("blank-name"); + blank_name.name = Some(" ".to_string()); + assert_eq!( + emby_item_to_movie::(ctx, &blank_name).unwrap().title.as_ref(), + format!("{capitalized} Movie") + ); + }); + } + + #[test] + fn minimal_item_maps_with_fallbacks() { + check_minimal::(); + check_minimal::(); + } + + #[test] + fn blank_id_is_dropped() { + with_ctx(true, |ctx| { + for id in ["", " "] { + let item = bare_item(id); + assert!(emby_item_to_movie::(ctx, &item).is_none()); + assert!(emby_item_to_series::(ctx, &item).is_none()); + assert!(emby_item_to_season::(ctx, &item).is_none()); + assert!(emby_item_to_episode::(ctx, &item).is_none()); + } + }); + } + + #[test] + fn library_kind_maps_movies_tvshows_only() { + assert_eq!(emby_library_kind(Some("movies")), MediaServerLibraryKind::Movies); + assert_eq!(emby_library_kind(Some(" TVShows ")), MediaServerLibraryKind::TvShows); + for other in [Some("mixed"), Some("boxsets"), Some("music"), Some(""), None] { + assert_eq!(emby_library_kind(other), MediaServerLibraryKind::Unsupported); + } + + let folders: Vec = serde_json::from_str(EMBY_VIRTUAL_FOLDERS_JSON).unwrap(); + assert_eq!(folders[0].library_key(), Some("library-redacted-movies")); + assert_eq!(folders[0].library_title(), Some("Movies")); + assert_eq!(folders[0].library_kind(), MediaServerLibraryKind::Movies); + assert_eq!(folders[1].library_kind(), MediaServerLibraryKind::TvShows); + assert_eq!(folders[2].library_kind(), MediaServerLibraryKind::Unsupported); + assert_eq!(folders[3].library_kind(), MediaServerLibraryKind::Unsupported); + + let id_only: EmbyVirtualFolderDto = + serde_json::from_value(serde_json::json!({ "Name": "X", "Id": "guid-1", "ItemId": " " })).unwrap(); + assert_eq!(id_only.library_key(), Some("guid-1")); + + let views: EmbyItemsPageDto = serde_json::from_str(JELLYFIN_VIEWS_JSON).unwrap(); + assert_eq!(views.items[0].library_key(), Some(views.items[0].id.as_str())); + assert_eq!(views.items[0].library_kind(), MediaServerLibraryKind::Movies); + assert_eq!(views.items[1].library_kind(), MediaServerLibraryKind::TvShows); + } + + #[test] + fn folders_resolve_through_shared_library_selection() { + let folders: Vec = serde_json::from_str(EMBY_VIRTUAL_FOLDERS_JSON).unwrap(); + let selectors = [MediaServerLibrarySelector::Name("TV".to_string())]; + let libraries = + crate::library_selection::select_libraries("emby", &input_name(), &server_id(), &selectors, &folders) + .expect("selected"); + assert_eq!(libraries[0].reference.library_id.as_ref(), "library-redacted-tv"); + assert_eq!(libraries[0].kind, MediaServerLibraryKind::TvShows); + } + + #[test] + fn provider_hints_are_lowercased_sorted_and_skip_blank_values() { + let item: EmbyItemDto = serde_json::from_value(serde_json::json!({ + "Id": "p", + "ProviderIds": { "Tvdb": "3", "Tmdb": "1", "IMDB": "tt2", "Zap2It": " ", "Empty": "" } + })) + .unwrap(); + let hints = provider_hints(&item.provider_ids); + assert_eq!( + hints.iter().map(|h| (h.namespace.as_ref(), h.value.as_ref())).collect::>(), + vec![("imdb", "tt2"), ("tmdb", "1"), ("tvdb", "3")] + ); + } + + #[test] + fn release_date_and_source_version_hint_parse_defensively() { + assert_eq!(release_date(Some("2024-03-01T00:00:00.0000000Z")).as_deref(), Some("2024-03-01")); + assert_eq!(release_date(Some("2024-03-01")).as_deref(), Some("2024-03-01")); + assert_eq!(release_date(Some("2024-13-45T00:00:00Z")), None); + assert_eq!(release_date(Some("not a date")), None); + assert_eq!(release_date(Some("20")), None); + assert_eq!(release_date(Some("ääääääääää")), None); + assert_eq!(release_date(None), None); + + assert_eq!(source_version_hint(Some("2024-04-02T10:15:30.1234567Z")).as_deref(), Some("1712052930")); + assert_eq!(source_version_hint(Some("2024-04-02T12:15:30+02:00")).as_deref(), Some("1712052930")); + assert_eq!(source_version_hint(Some("1969-12-31T00:00:00Z")), None); + assert_eq!(source_version_hint(Some("garbage")), None); + assert_eq!(source_version_hint(None), None); + } + + #[test] + fn audience_rating_uses_one_decimal() { + let mut item = bare_item("r"); + item.community_rating = Some(8.0); + let movie = with_ctx(true, |ctx| emby_item_to_movie::(ctx, &item)).unwrap(); + assert_eq!(movie.descriptive_facts.unwrap().audience_rating.as_deref(), Some("8.0")); + } + + #[test] + fn technical_facts_fall_back_to_item_runtime_and_match_stream_type_case_insensitively() { + let item: EmbyItemDto = serde_json::from_value(serde_json::json!({ + "Id": "t", + "RunTimeTicks": 36_000_000_000u64, + "MediaSources": [{ + "Id": "s", + "MediaStreams": [ + { "Type": "audio", "Codec": "eac3", "Channels": 8 }, + { "Type": "VIDEO", "Codec": "av1", "Width": 3840, "Height": 2160 } + ] + }] + })) + .unwrap(); + let movie = with_ctx(true, |ctx| emby_item_to_movie::(ctx, &item)).unwrap(); + let technical = movie.technical_facts.expect("technical facts"); + assert_eq!(technical.duration_secs, Some(3600)); + assert_eq!(technical.video.unwrap().codec.as_deref(), Some("av1")); + assert_eq!(technical.audio.unwrap().channels, Some(8)); + } +} diff --git a/backend/media-server/src/emby/mod.rs b/backend/media-server/src/emby/mod.rs index a07dce5c0..b1907096c 100644 --- a/backend/media-server/src/emby/mod.rs +++ b/backend/media-server/src/emby/mod.rs @@ -1 +1,6 @@ +pub mod client; pub mod dto; +pub mod flavor; +pub mod mapper; + +pub type EmbyCatalogClient = client::EmbyFamilyCatalogClient; diff --git a/backend/media-server/src/jellyfin/dto.rs b/backend/media-server/src/jellyfin/dto.rs index 3db022678..c963f45cf 100644 --- a/backend/media-server/src/jellyfin/dto.rs +++ b/backend/media-server/src/jellyfin/dto.rs @@ -8,7 +8,13 @@ pub type JellyfinItemsPageDto = crate::emby::dto::EmbyItemsPageDto; pub type JellyfinViewDto = crate::emby::dto::EmbyViewDto; pub type JellyfinItemDto = crate::emby::dto::EmbyItemDto; pub type JellyfinMediaSourceDto = crate::emby::dto::EmbyMediaSourceDto; -pub type JellyfinPlaybackInfoDto = crate::emby::dto::EmbyPlaybackInfoDto; +pub type JellyfinNameDto = crate::emby::dto::EmbyNameDto; +pub type JellyfinPersonDto = crate::emby::dto::EmbyPersonDto; +pub type JellyfinMediaStreamDto = crate::emby::dto::EmbyMediaStreamDto; +pub type JellyfinVirtualFolderDto = crate::emby::dto::EmbyVirtualFolderDto; +pub type JellyfinUserDto = crate::emby::dto::EmbyUserDto; +pub type JellyfinAuthenticationResultDto = crate::emby::dto::EmbyAuthenticationResultDto; +pub type JellyfinAuthenticateByNameRequest<'a> = crate::emby::dto::EmbyAuthenticateByNameRequest<'a>; #[cfg(test)] mod tests { diff --git a/backend/media-server/src/jellyfin/flavor.rs b/backend/media-server/src/jellyfin/flavor.rs new file mode 100644 index 000000000..942b4bd4f --- /dev/null +++ b/backend/media-server/src/jellyfin/flavor.rs @@ -0,0 +1,94 @@ +use crate::{ + emby::flavor::{ + impl_emby_family_flavor, sensitive_header_value, validate_token, EmbyClientIdentity, EmbyEndpoint, EmbyPath, + UserScope, + }, + MediaServerError, +}; +use reqwest::header::{HeaderMap, AUTHORIZATION}; + +#[derive(Debug, Clone, Copy)] +pub struct Jellyfin; + +// Jellyfin 10.11 deprecated and 12.0 removes the `X-Emby-*` / `X-MediaBrowser-*` +// headers and `api_key`, so the `Authorization` header is the only credential carrier. +impl_emby_family_flavor!(Jellyfin, "jellyfin", { + fn auth_headers(identity: &EmbyClientIdentity, token: Option<&str>) -> Result { + let token = token.map(|token| validate_token(token, "jellyfin")).transpose()?; + let mut headers = HeaderMap::with_capacity(1); + headers.insert(AUTHORIZATION, sensitive_header_value(&identity.media_browser_value(token), "jellyfin")?); + Ok(headers) + } + + fn items_path(user_id: Option<&str>) -> EmbyEndpoint<'_> { + let user_scope = if user_id.is_some() { UserScope::Query } else { UserScope::None }; + EmbyEndpoint { path: EmbyPath::one("Items"), user_scope } + } + + fn user_views_path(_user_id: &str) -> EmbyEndpoint<'_> { + EmbyEndpoint { path: EmbyPath::one("UserViews"), user_scope: UserScope::Query } + } +}); + +#[cfg(test)] +mod tests { + use super::*; + use crate::{emby::flavor::EmbyFamilyFlavor, MediaServerErrorKind}; + use shared::utils::short_hash; + + fn identity() -> EmbyClientIdentity { EmbyClientIdentity::new("input", Some("alice")) } + + fn expected_identity() -> String { + format!( + "MediaBrowser Client=\"Tuliprox\", Device=\"Tuliprox\", DeviceId=\"tuliprox-{}\", Version=\"{}\"", + short_hash("input\u{1f}alice"), + env!("CARGO_PKG_VERSION") + ) + } + + #[test] + fn auth_headers_use_only_the_authorization_header() { + let headers = Jellyfin::auth_headers(&identity(), Some("secret")).expect("headers build"); + + assert_eq!(headers.len(), 1); + let value = headers.get(AUTHORIZATION).expect("authorization header").to_str().expect("ascii"); + assert_eq!(value, format!("{}, Token=\"secret\"", expected_identity())); + assert!(headers.get(AUTHORIZATION).expect("authorization header").is_sensitive()); + } + + #[test] + fn identity_only_headers_carry_no_token() { + let headers = Jellyfin::auth_headers(&identity(), None).expect("headers build"); + + assert_eq!(headers.len(), 1); + assert_eq!( + headers.get(AUTHORIZATION).expect("authorization header").to_str().expect("ascii"), + expected_identity() + ); + } + + #[test] + fn tokens_that_cannot_be_quoted_are_auth_denied() { + for token in ["a\"b", "a\\b", "a b", "a\nb", "tök\u{e9}n", ""] { + let error = Jellyfin::auth_headers(&identity(), Some(token)).expect_err("token is rejected"); + + assert_eq!(error.kind, MediaServerErrorKind::MediaServerAuthDenied, "token {token:?}"); + } + } + + #[test] + fn paths_scope_to_the_user_with_a_query_parameter() { + assert_eq!( + Jellyfin::items_path(None), + EmbyEndpoint { path: EmbyPath::one("Items"), user_scope: UserScope::None } + ); + assert_eq!( + Jellyfin::items_path(Some("u1")), + EmbyEndpoint { path: EmbyPath::one("Items"), user_scope: UserScope::Query } + ); + assert_eq!( + Jellyfin::user_views_path("u1"), + EmbyEndpoint { path: EmbyPath::one("UserViews"), user_scope: UserScope::Query } + ); + } +} diff --git a/backend/media-server/src/jellyfin/mod.rs b/backend/media-server/src/jellyfin/mod.rs index a07dce5c0..3956a2c75 100644 --- a/backend/media-server/src/jellyfin/mod.rs +++ b/backend/media-server/src/jellyfin/mod.rs @@ -1 +1,4 @@ pub mod dto; +pub mod flavor; + +pub type JellyfinCatalogClient = crate::emby::client::EmbyFamilyCatalogClient; diff --git a/backend/media-server/src/lib.rs b/backend/media-server/src/lib.rs index 79d48ec47..561e26278 100644 --- a/backend/media-server/src/lib.rs +++ b/backend/media-server/src/lib.rs @@ -4,11 +4,14 @@ //! Source Acquisition boundary. Playlist Curation and Stream Brokerage should //! depend on the typed concepts exported here instead of provider-specific DTOs. +pub mod any_client; pub mod catalog; pub mod client; pub mod emby; pub mod errors; pub mod jellyfin; +pub(crate) mod library_selection; +pub(crate) mod operation; pub mod playback; pub mod playlist_mapper; pub mod plex; @@ -17,8 +20,15 @@ pub mod types; #[cfg(test)] pub mod test_fixtures; +#[cfg(test)] +pub(crate) mod test_server; -use std::fmt::Write as _; +use std::{fmt::Write as _, sync::Arc}; + +/// Trimmed, shared copy of `value`, or `None` when it is absent or blank. +pub(crate) fn non_blank_arc(value: Option<&str>) -> Option> { + value.map(str::trim).filter(|value| !value.is_empty()).map(Arc::::from) +} /// Percent-encode a single URL path segment using the unreserved-character /// rules of [RFC 3986 §2.3]: alphanumeric, `-`, `.`, `_`, `~` are passed @@ -39,6 +49,7 @@ pub fn encode_url_path_segment(value: &str) -> String { encoded } +pub use any_client::*; pub use catalog::*; pub use client::*; pub use errors::*; diff --git a/backend/media-server/src/library_selection.rs b/backend/media-server/src/library_selection.rs new file mode 100644 index 000000000..c3c991dbb --- /dev/null +++ b/backend/media-server/src/library_selection.rs @@ -0,0 +1,214 @@ +use crate::{non_blank_arc, MediaServerError, MediaServerErrorKind, MediaServerLibrary, MediaServerLibraryRef}; +use shared::model::{MediaServerLibraryKind, MediaServerLibrarySelector}; +use std::sync::Arc; + +/// A library as exposed by a media-server wire DTO, reduced to what selector +/// matching and `MediaServerLibrary` construction need. +pub(crate) trait MediaServerLibraryCandidate { + fn library_key(&self) -> Option<&str>; + fn library_title(&self) -> Option<&str>; + fn library_kind(&self) -> MediaServerLibraryKind; +} + +pub(crate) fn library_matches_selector( + candidate: &C, + selector: &MediaServerLibrarySelector, +) -> bool { + match selector { + MediaServerLibrarySelector::Name(name) => matches_trimmed(candidate.library_title(), name), + MediaServerLibrarySelector::Detailed(details) => { + let identity_matches = + details.key.as_deref().is_some_and(|key| matches_trimmed(candidate.library_key(), key)) + || details.id.as_deref().is_some_and(|id| matches_trimmed(candidate.library_key(), id)) + || details.name.as_deref().is_some_and(|name| matches_trimmed(candidate.library_title(), name)); + identity_matches + && details.kind.is_none_or(|expected| { + expected != MediaServerLibraryKind::Unsupported && candidate.library_kind() == expected + }) + } + } +} + +/// Resolves every selector to exactly one visible library, in selector order. +pub(crate) fn select_libraries( + provider: &'static str, + input_name: &Arc, + server_id: &Arc, + selectors: &[MediaServerLibrarySelector], + candidates: &[C], +) -> Result, MediaServerError> { + let unavailable = |detail: &'static str| { + MediaServerError::new(MediaServerErrorKind::MediaServerLibraryUnavailable).provider(provider).detail(detail) + }; + let mut libraries = Vec::with_capacity(selectors.len()); + for selector in selectors { + let mut matches = candidates.iter().filter(|candidate| library_matches_selector(*candidate, selector)); + let candidate = match (matches.next(), matches.next()) { + (None, _) => return Err(unavailable("selected library was not visible")), + (Some(candidate), None) => candidate, + (Some(_), Some(_)) => return Err(unavailable("selected library name was ambiguous")), + }; + let library_id = non_blank_arc(candidate.library_key()) + .ok_or_else(|| unavailable("selected library did not expose a stable key"))?; + let name = non_blank_arc(candidate.library_title()).unwrap_or_else(|| Arc::clone(&library_id)); + libraries.push(MediaServerLibrary { + reference: MediaServerLibraryRef { + input_name: Arc::clone(input_name), + server_id: Arc::clone(server_id), + library_id, + }, + name, + kind: candidate.library_kind(), + }); + } + Ok(libraries) +} + +fn matches_trimmed(candidate: Option<&str>, expected: &str) -> bool { + candidate.is_some_and(|candidate| candidate.trim() == expected.trim()) +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::MediaServerLibrarySelectorDetailsDto; + + struct TestCandidate { + key: Option<&'static str>, + title: Option<&'static str>, + kind: MediaServerLibraryKind, + } + + impl MediaServerLibraryCandidate for TestCandidate { + fn library_key(&self) -> Option<&str> { self.key } + + fn library_title(&self) -> Option<&str> { self.title } + + fn library_kind(&self) -> MediaServerLibraryKind { self.kind } + } + + fn candidate(key: &'static str, title: &'static str, kind: MediaServerLibraryKind) -> TestCandidate { + TestCandidate { key: Some(key), title: Some(title), kind } + } + + fn details( + id: Option<&str>, + key: Option<&str>, + name: Option<&str>, + kind: Option, + ) -> MediaServerLibrarySelector { + MediaServerLibrarySelector::Detailed(MediaServerLibrarySelectorDetailsDto { + id: id.map(str::to_string), + key: key.map(str::to_string), + name: name.map(str::to_string), + kind, + }) + } + + fn select( + selectors: &[MediaServerLibrarySelector], + candidates: &[TestCandidate], + ) -> Result, MediaServerError> { + select_libraries("test", &Arc::::from("input"), &Arc::::from("server"), selectors, candidates) + } + + #[test] + fn name_selector_matches_trimmed_title() { + let candidate = + TestCandidate { key: Some("1"), title: Some(" Movies "), kind: MediaServerLibraryKind::Movies }; + + assert!(library_matches_selector(&candidate, &MediaServerLibrarySelector::Name(" Movies ".to_string()))); + assert!(!library_matches_selector(&candidate, &MediaServerLibrarySelector::Name("Shows".to_string()))); + assert!(!library_matches_selector( + &TestCandidate { key: Some("1"), title: None, kind: MediaServerLibraryKind::Movies }, + &MediaServerLibrarySelector::Name("Movies".to_string()) + )); + } + + #[test] + fn key_and_id_selectors_match_library_key() { + let candidate = candidate("42", "Movies", MediaServerLibraryKind::Movies); + + assert!(library_matches_selector(&candidate, &details(None, Some(" 42 "), None, None))); + assert!(library_matches_selector(&candidate, &details(Some("42"), None, None, None))); + assert!(library_matches_selector(&candidate, &details(None, None, Some("Movies"), None))); + assert!(!library_matches_selector(&candidate, &details(None, Some("43"), None, None))); + assert!(!library_matches_selector(&candidate, &details(None, None, None, None))); + } + + #[test] + fn kind_filter_rejects_mismatch_and_unsupported() { + let movies = candidate("1", "Movies", MediaServerLibraryKind::Movies); + let mixed = candidate("2", "Mixed", MediaServerLibraryKind::Unsupported); + + assert!(library_matches_selector( + &movies, + &details(None, Some("1"), None, Some(MediaServerLibraryKind::Movies)) + )); + assert!(!library_matches_selector( + &movies, + &details(None, Some("1"), None, Some(MediaServerLibraryKind::TvShows)) + )); + assert!(!library_matches_selector( + &mixed, + &details(None, Some("2"), None, Some(MediaServerLibraryKind::Unsupported)) + )); + assert!(library_matches_selector(&mixed, &details(None, Some("2"), None, None))); + } + + #[test] + fn duplicate_title_is_ambiguous() { + let candidates = [ + candidate("1", "Movies", MediaServerLibraryKind::Movies), + candidate("2", "Movies", MediaServerLibraryKind::Movies), + ]; + + let error = select(&[MediaServerLibrarySelector::Name("Movies".to_string())], &candidates) + .expect_err("duplicate title is ambiguous"); + assert_eq!(error.kind, MediaServerErrorKind::MediaServerLibraryUnavailable); + assert_eq!(error.provider, Some("test")); + assert_eq!(error.detail_text(), Some("selected library name was ambiguous")); + + let libraries = select(&[details(None, Some("2"), None, None)], &candidates).expect("key disambiguates"); + assert_eq!(libraries[0].reference.library_id.as_ref(), "2"); + } + + #[test] + fn missing_library_is_unavailable() { + let candidates = [candidate("1", "Movies", MediaServerLibraryKind::Movies)]; + + let error = select(&[MediaServerLibrarySelector::Name("Shows".to_string())], &candidates) + .expect_err("library is not visible"); + assert_eq!(error.kind, MediaServerErrorKind::MediaServerLibraryUnavailable); + assert_eq!(error.detail_text(), Some("selected library was not visible")); + + let blank_key = + [TestCandidate { key: Some(" "), title: Some("Movies"), kind: MediaServerLibraryKind::Movies }]; + let error = select(&[MediaServerLibrarySelector::Name("Movies".to_string())], &blank_key) + .expect_err("blank key is rejected"); + assert_eq!(error.kind, MediaServerErrorKind::MediaServerLibraryUnavailable); + assert_eq!(error.detail_text(), Some("selected library did not expose a stable key")); + } + + #[test] + fn output_follows_selector_order() { + let candidates = [ + candidate("1", "Movies", MediaServerLibraryKind::Movies), + candidate("2", "Shows", MediaServerLibraryKind::TvShows), + ]; + + let libraries = select( + &[ + MediaServerLibrarySelector::Name("Shows".to_string()), + MediaServerLibrarySelector::Name("Movies".to_string()), + ], + &candidates, + ) + .expect("libraries selected"); + + assert_eq!(libraries.iter().map(|library| library.name.as_ref()).collect::>(), ["Shows", "Movies"]); + assert_eq!(libraries[0].kind, MediaServerLibraryKind::TvShows); + assert_eq!(libraries[0].reference.input_name.as_ref(), "input"); + assert_eq!(libraries[0].reference.server_id.as_ref(), "server"); + } +} diff --git a/backend/media-server/src/operation.rs b/backend/media-server/src/operation.rs new file mode 100644 index 000000000..62deafeed --- /dev/null +++ b/backend/media-server/src/operation.rs @@ -0,0 +1,85 @@ +use crate::{MediaServerError, MediaServerErrorKind}; +use reqwest::StatusCode; + +/// The phase of a media-server interaction a request belongs to. It decides +/// which `MediaServerErrorKind`s a failed request surfaces. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(crate) enum MediaServerOperation { + Discovery, + Catalog, + Playback, +} + +impl MediaServerOperation { + pub(crate) const fn not_found_kind(self) -> MediaServerErrorKind { + match self { + Self::Discovery => MediaServerErrorKind::MediaServerUnavailable, + Self::Catalog => MediaServerErrorKind::MediaServerLibraryUnavailable, + Self::Playback => MediaServerErrorKind::MediaServerItemNotFound, + } + } + + pub(crate) const fn fallback_kind(self) -> MediaServerErrorKind { + match self { + Self::Discovery => MediaServerErrorKind::MediaServerDiscoveryFailed, + Self::Catalog => MediaServerErrorKind::MediaServerCatalogDecodeFailed, + Self::Playback => MediaServerErrorKind::MediaServerStreamOpenFailed, + } + } + + pub(crate) fn status_error(self, status: StatusCode, provider: &'static str) -> MediaServerError { + MediaServerError::from_http_status_with_fallback(status, self.not_found_kind(), self.fallback_kind()) + .provider(provider) + } + + pub(crate) fn reqwest_error(self, err: &reqwest::Error, provider: &'static str) -> MediaServerError { + MediaServerError::from_reqwest_error_with_fallback(err, self.not_found_kind(), self.fallback_kind()) + .provider(provider) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn operations_map_to_stable_error_kinds() { + assert_eq!( + MediaServerOperation::Discovery.status_error(StatusCode::NOT_FOUND, "test").kind, + MediaServerErrorKind::MediaServerUnavailable + ); + assert_eq!( + MediaServerOperation::Discovery.status_error(StatusCode::BAD_GATEWAY, "test").kind, + MediaServerErrorKind::MediaServerDiscoveryFailed + ); + assert_eq!( + MediaServerOperation::Catalog.status_error(StatusCode::NOT_FOUND, "test").kind, + MediaServerErrorKind::MediaServerLibraryUnavailable + ); + assert_eq!( + MediaServerOperation::Catalog.status_error(StatusCode::BAD_GATEWAY, "test").kind, + MediaServerErrorKind::MediaServerCatalogDecodeFailed + ); + assert_eq!( + MediaServerOperation::Playback.status_error(StatusCode::NOT_FOUND, "test").kind, + MediaServerErrorKind::MediaServerItemNotFound + ); + assert_eq!( + MediaServerOperation::Playback.status_error(StatusCode::BAD_GATEWAY, "test").kind, + MediaServerErrorKind::MediaServerStreamOpenFailed + ); + } + + #[test] + fn status_error_carries_provider_status_and_auth_kinds() { + let error = MediaServerOperation::Catalog.status_error(StatusCode::UNAUTHORIZED, "emby"); + + assert_eq!(error.kind, MediaServerErrorKind::MediaServerAuthDenied); + assert_eq!(error.provider, Some("emby")); + assert_eq!(error.status, Some(StatusCode::UNAUTHORIZED)); + assert_eq!( + MediaServerOperation::Playback.status_error(StatusCode::TOO_MANY_REQUESTS, "emby").kind, + MediaServerErrorKind::MediaServerRateLimited + ); + } +} diff --git a/backend/media-server/src/playback.rs b/backend/media-server/src/playback.rs index 4581d36a7..9e3ab852d 100644 --- a/backend/media-server/src/playback.rs +++ b/backend/media-server/src/playback.rs @@ -1,26 +1,14 @@ use crate::{ BoxedMediaServerStream, MediaServerCatalogClient, MediaServerError, MediaServerErrorKind, MediaServerImageRef, - MediaServerResourceResponse, MediaServerStreamRef, MediaServerStreamResponse, + MediaServerStreamRef, MediaServerStreamResponse, }; -use bytes::Bytes; -use futures::{stream, StreamExt}; use reqwest::{ header::{HeaderMap, ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, LAST_MODIFIED}, StatusCode, }; -use shared::{ - model::{InputType, PlaylistItemType}, - utils::hex_digit, -}; +use shared::utils::hex_digit; use std::{fmt, sync::Arc}; -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PlaybackOrigin { - Provider, - LocalLibrary, - MediaServer(MediaServerStreamRef), -} - pub struct MediaServerProxyResponse { pub status: StatusCode, pub headers: HeaderMap, @@ -37,23 +25,6 @@ impl fmt::Debug for MediaServerProxyResponse { } } -pub fn classify_playback_origin( - input_type: InputType, - item_type: PlaylistItemType, - input_name: &Arc, - item_url: &str, -) -> Result { - if input_type.is_media_server() || item_url.starts_with("media-server://") { - return parse_media_server_stream_ref(input_name, item_url).map(PlaybackOrigin::MediaServer); - } - - if matches!(item_type, PlaylistItemType::LocalVideo | PlaylistItemType::LocalSeries) { - return Ok(PlaybackOrigin::LocalLibrary); - } - - Ok(PlaybackOrigin::Provider) -} - pub async fn media_server_stream_response( client: &C, stream_ref: &MediaServerStreamRef, @@ -74,15 +45,7 @@ where C: MediaServerCatalogClient, { let response = client.open_image(image_ref).await?; - Ok(media_server_resource_to_proxy_response(response)) -} - -fn media_server_resource_to_proxy_response(response: MediaServerResourceResponse) -> MediaServerProxyResponse { - MediaServerProxyResponse { - status: response.status, - headers: safe_media_server_response_headers(&response.headers), - body: single_chunk_stream(response.body), - } + Ok(media_server_stream_to_proxy_response(response)) } fn media_server_stream_to_proxy_response(response: MediaServerStreamResponse) -> MediaServerProxyResponse { @@ -93,10 +56,6 @@ fn media_server_stream_to_proxy_response(response: MediaServerStreamResponse) -> } } -fn single_chunk_stream(body: Bytes) -> BoxedMediaServerStream { - stream::once(async move { Ok::(body) }).boxed() -} - pub fn safe_media_server_response_headers(headers: &HeaderMap) -> HeaderMap { let mut safe = HeaderMap::new(); for name in [CONTENT_TYPE, CONTENT_LENGTH, CONTENT_RANGE, ACCEPT_RANGES, ETAG, LAST_MODIFIED] { @@ -230,6 +189,7 @@ mod tests { MediaServerLibraryRef, MediaServerMovie, MediaServerPage, MediaServerPageRequest, MediaServerSeason, MediaServerSeries, MediaServerStatus, }; + use bytes::Bytes; use futures::{stream, StreamExt}; use reqwest::header::{HeaderValue, AUTHORIZATION}; use std::sync::{Arc as StdArc, Mutex}; @@ -305,11 +265,11 @@ mod tests { fn open_image( &self, _image_ref: &MediaServerImageRef, - ) -> impl std::future::Future> { - std::future::ready(Ok(MediaServerResourceResponse { + ) -> impl std::future::Future> { + std::future::ready(Ok(MediaServerStreamResponse { status: StatusCode::OK, headers: HeaderMap::new(), - body: Bytes::new(), + body: stream::empty().boxed(), })) } } @@ -338,25 +298,14 @@ mod tests { } #[test] - fn classifies_media_server_local_and_provider_origins() { + fn parses_media_server_stream_refs() { let input_name = StdArc::::from("media_server"); - let media_server = classify_playback_origin( - InputType::Plex, - PlaylistItemType::Video, + let plex = parse_media_server_stream_ref( &input_name, "media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted%2Ffile.mkv", ) .expect("media_server ref parses"); - assert!(matches!(media_server, PlaybackOrigin::MediaServer(MediaServerStreamRef::Plex { .. }))); - - let local = classify_playback_origin( - InputType::Library, - PlaylistItemType::LocalVideo, - &input_name, - "file:///tmp/a.mkv", - ) - .expect("local classifies"); - assert_eq!(local, PlaybackOrigin::LocalLibrary); + assert!(matches!(plex, MediaServerStreamRef::Plex { .. })); let encoded = parse_media_server_stream_ref( &input_name, @@ -377,14 +326,9 @@ mod tests { .expect_err("unavailable sentinel should map to a stable playback error"); assert_eq!(unavailable.kind, MediaServerErrorKind::NoDirectPlayableMediaServerSource); - let provider = classify_playback_origin( - InputType::M3u, - PlaylistItemType::Live, - &input_name, - "http://example.invalid/live", - ) - .expect("provider classifies"); - assert_eq!(provider, PlaybackOrigin::Provider); + let not_media_server = parse_media_server_stream_ref(&input_name, "http://example.invalid/live") + .expect_err("plain provider URLs are not media-server refs"); + assert_eq!(not_media_server.kind, MediaServerErrorKind::MediaServerStreamOpenFailed); } #[test] diff --git a/backend/media-server/src/plex/client.rs b/backend/media-server/src/plex/client.rs index b2e4aa9ef..37651edd0 100644 --- a/backend/media-server/src/plex/client.rs +++ b/backend/media-server/src/plex/client.rs @@ -1,21 +1,21 @@ use crate::{ encode_url_path_segment, + library_selection::select_libraries, + operation::MediaServerOperation, plex::{ - dto::{PlexMediaContainerDto, PlexResourcesDto, PlexSectionDto, PlexSectionsDto}, - 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, - }, + dto::{PlexMediaContainerDto, PlexResourcesDto, PlexSectionsDto}, + mapper::{plex_directory_to_season, plex_directory_to_series, plex_video_to_episode, plex_video_to_movie}, }, + redaction::redacted_secret, MediaServerCatalogClient, MediaServerEpisode, MediaServerError, MediaServerErrorKind, MediaServerHttpClient, MediaServerImageRef, MediaServerKind, MediaServerLibrary, MediaServerLibraryRef, MediaServerMovie, MediaServerPage, - MediaServerPageRequest, MediaServerResourceResponse, MediaServerSeason, MediaServerSeries, MediaServerStatus, - MediaServerStreamRef, MediaServerStreamResponse, + MediaServerPageRequest, MediaServerSeason, MediaServerSeries, MediaServerStatus, MediaServerStreamRef, + MediaServerStreamResponse, }; use futures::{StreamExt, TryStreamExt}; use reqwest::{ header::{HeaderName, HeaderValue, RANGE}, - Method, StatusCode, + Method, }; use serde::{de::DeserializeOwned, Deserialize}; use shared::model::MediaServerLibrarySelector; @@ -37,7 +37,7 @@ static PLEX_CONNECTION_CACHE: LazyLock, pub account_token: Option, @@ -48,6 +48,20 @@ pub struct PlexClientSettings { pub libraries: Vec, } +impl std::fmt::Debug for PlexClientSettings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PlexClientSettings") + .field("token", &redacted_secret(self.token.as_deref())) + .field("account_token", &redacted_secret(self.account_token.as_deref())) + .field("server_id", &self.server_id) + .field("server_name", &self.server_name) + .field("prefer_https", &self.prefer_https) + .field("allow_relay", &self.allow_relay) + .field("libraries", &self.libraries) + .finish() + } +} + #[derive(Debug, Clone)] struct PlexClientConfig { input_name: Arc, @@ -168,7 +182,7 @@ impl PlexCatalogClient { .detail("Plex resource discovery requires media_server.account_token") })?; let resources: PlexResourcesDto = - self.get_xml(PLEX_TV_RESOURCES_URL, &account_token, PlexOperation::Discovery).await?; + self.get_xml(PLEX_TV_RESOURCES_URL, &account_token, MediaServerOperation::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(|| { @@ -200,12 +214,12 @@ impl PlexCatalogClient { 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 + self.get_xml(&url, token, MediaServerOperation::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 + self.get_xml(&url, &connection.token, MediaServerOperation::Catalog).await } async fn fetch_catalog_page( @@ -226,23 +240,23 @@ impl PlexCatalogClient { if include_guids { append_query_pair(&mut url, "includeGuids", "1"); } - self.get_xml(&url, &connection.token, PlexOperation::Catalog).await + self.get_xml(&url, &connection.token, MediaServerOperation::Catalog).await } - async fn get_xml(&self, url: &str, token: &Arc, operation: PlexOperation) -> Result + async fn get_xml( + &self, + url: &str, + token: &Arc, + operation: MediaServerOperation, + ) -> 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") - })?; + let body = response + .text() + .await + .map_err(|err| operation.reqwest_error(&err, "plex").detail("Plex response body read failed"))?; quick_xml::de::from_str(&body).map_err(|err| { MediaServerError::new(operation.fallback_kind()) .provider("plex") @@ -254,22 +268,18 @@ impl PlexCatalogClient { &self, url: &str, token: &Arc, - operation: PlexOperation, + operation: MediaServerOperation, ) -> 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 request = self.http.request_for(Method::GET, url, operation); 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}"))); + return Err(operation.status_error(status, "plex").detail(format!("Plex request returned {status}"))); } Ok(response) } @@ -282,7 +292,13 @@ impl MediaServerCatalogClient for PlexCatalogClient { 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) + select_libraries( + "plex", + &self.config.input_name, + &connection.status.server_id, + &self.config.libraries, + §ions.directories, + ) } async fn list_movies( @@ -420,30 +436,21 @@ impl MediaServerCatalogClient for PlexCatalogClient { 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) + return Err(MediaServerOperation::Playback + .status_error(status, "plex") .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") + MediaServerOperation::Playback.reqwest_error(&err, "plex").detail("Plex stream body read failed") }) .boxed(); Ok(MediaServerStreamResponse { status, headers, body }) } - async fn open_image( - &self, - image_ref: &MediaServerImageRef, - ) -> Result { + 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") @@ -468,49 +475,17 @@ impl MediaServerCatalogClient for PlexCatalogClient { } let url = pms_image_url(&connection.base_url, image_path)?; - let response: reqwest::Response = self.get_response(&url, &connection.token, PlexOperation::Playback).await?; + let response: reqwest::Response = + self.get_response(&url, &connection.token, MediaServerOperation::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") + let body = response + .bytes_stream() + .map_err(|err| { + MediaServerOperation::Playback.reqwest_error(&err, "plex").detail("Plex image body read failed") + }) + .boxed(); + Ok(MediaServerStreamResponse { status, headers, body }) } } @@ -558,39 +533,6 @@ impl PlexMediaContainerDto { } } -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::plex::dto::PlexResourceDto], config: &PlexClientConfig, @@ -820,15 +762,15 @@ fn non_blank(value: &str) -> Option<&str> { #[cfg(test)] mod tests { use super::*; - use crate::plex::dto::{PlexConnectionDto, PlexResourceDto}; + use crate::{ + plex::dto::{PlexConnectionDto, PlexResourceDto, PlexSectionDto}, + test_server::{start_test_server, test_response}, + }; 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 hyper::{body::Incoming, http, Request, Response}; + use reqwest::StatusCode; use shared::model::MediaServerLibrarySelectorDetailsDto; use std::{ convert::Infallible, @@ -837,7 +779,7 @@ mod tests { Arc as StdArc, }, }; - use tokio::{net::TcpListener, sync::Mutex as TokioMutex}; + use tokio::sync::Mutex as TokioMutex; const PLEX_TEST_TOKEN: &str = "pms-token-redacted"; @@ -1007,7 +949,14 @@ mod tests { section_type: Some("movie".to_string()), }, ]; - let error = select_libraries(&config(), &"machine-redacted".into(), §ions).expect_err("duplicate title"); + let error = select_libraries( + "plex", + &"media_server".into(), + &"machine-redacted".into(), + &config().libraries, + §ions, + ) + .expect_err("duplicate title"); assert_eq!(error.kind, MediaServerErrorKind::MediaServerLibraryUnavailable); } @@ -1032,7 +981,8 @@ mod tests { }, ]; let libraries = - select_libraries(&config, &"machine-redacted".into(), §ions).expect("stable key disambiguates"); + select_libraries("plex", &"media_server".into(), &"machine-redacted".into(), &config.libraries, §ions) + .expect("stable key disambiguates"); assert_eq!(libraries[0].reference.library_id.as_ref(), "2"); } @@ -1096,10 +1046,6 @@ mod tests { ); } - 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() -> PlexClientSettings { PlexClientSettings { token: Some(PLEX_TEST_TOKEN.to_string()), @@ -1120,13 +1066,13 @@ mod tests { state: StdArc, ) -> Result>, Infallible> { if !plex_test_token_is_valid(&req) { - return Ok(plex_test_response(StatusCode::UNAUTHORIZED, b"")); + return Ok(test_response(StatusCode::UNAUTHORIZED, b"")); } match req.uri().path() { "/identity" => { state.identity_requests.fetch_add(1, Ordering::SeqCst); - Ok(plex_test_response( + Ok(test_response( StatusCode::OK, br#""#, )) @@ -1152,34 +1098,19 @@ mod tests { .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"")), + _ => Ok(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) + async fn start_plex_test_server(state: &StdArc) -> (String, tokio::task::JoinHandle<()>) { + let state = StdArc::clone(state); + start_test_server(move |req| plex_playback_handler(req, StdArc::clone(&state))).await } #[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 (base_url, server) = start_plex_test_server(&state).await; let media_server = plex_test_media_server_config(); let client = PlexCatalogClient::new( "media_server".into(), @@ -1213,10 +1144,29 @@ mod tests { server.abort(); } + #[test] + fn settings_debug_hides_secrets() { + let settings = PlexClientSettings { + token: Some("tok-secret".to_string()), + account_token: Some("account-secret".to_string()), + server_id: Some("machine-1".to_string()), + ..PlexClientSettings::default() + }; + + let debug = format!("{settings:?}"); + + for secret in ["tok-secret", "account-secret"] { + assert!(!debug.contains(secret), "{secret} leaked into {debug}"); + } + assert!(debug.contains("token: Some(\"***\")") && debug.contains("account_token: Some(\"***\")"), "{debug}"); + assert!(debug.contains("machine-1"), "{debug}"); + assert!(format!("{:?}", PlexClientSettings::default()).contains("account_token: None")); + } + #[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 (base_url, server) = start_plex_test_server(&state).await; let media_server = plex_test_media_server_config(); let client = PlexCatalogClient::new( "media_server".into(), @@ -1238,7 +1188,9 @@ mod tests { 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"); + let chunks = response.body.collect::>().await; + let body: Vec = chunks.into_iter().flat_map(|chunk| chunk.expect("image chunk").to_vec()).collect(); + assert_eq!(body, b"jpeg"); server.abort(); } @@ -1246,7 +1198,7 @@ mod tests { #[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 (base_url, server) = start_plex_test_server(&state).await; let media_server = plex_test_media_server_config(); let stream_ref = MediaServerStreamRef::Plex { input_name: "media_server".into(), diff --git a/backend/media-server/src/plex/mapper.rs b/backend/media-server/src/plex/mapper.rs index 12696a86a..318fe2c82 100644 --- a/backend/media-server/src/plex/mapper.rs +++ b/backend/media-server/src/plex/mapper.rs @@ -1,10 +1,12 @@ use crate::{ + library_selection::MediaServerLibraryCandidate, + non_blank_arc, plex::dto::{PlexDirectoryDto, PlexGuidDto, PlexMediaDto, PlexSectionDto, PlexVideoDto}, MediaServerAudioTechnicalFacts, MediaServerDescriptiveFacts, MediaServerEpisode, MediaServerImageRef, MediaServerLibrary, MediaServerLibraryRef, MediaServerMovie, MediaServerProviderIdHint, MediaServerSeason, MediaServerSeries, MediaServerStreamRef, MediaServerTechnicalFacts, MediaServerVideoTechnicalFacts, }; -use shared::model::{MediaServerLibraryKind, MediaServerLibrarySelector}; +use shared::model::MediaServerLibraryKind; use std::{cmp::Reverse, collections::HashSet, sync::Arc}; pub fn plex_section_to_library( @@ -12,8 +14,8 @@ pub fn plex_section_to_library( 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()); + let library_id = non_blank_arc(section.key.as_deref())?; + let name = non_blank_arc(section.title.as_deref()).unwrap_or_else(|| library_id.clone()); Some(MediaServerLibrary { reference: MediaServerLibraryRef { input_name: Arc::clone(input_name), @@ -25,28 +27,14 @@ pub fn plex_section_to_library( }) } -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 rating_key = non_blank_arc(video.rating_key.as_deref())?; + let title = non_blank_arc(video.title.as_deref()).unwrap_or_else(|| Arc::::from("Plex Movie")); let media = first_media(&video.media); Some(MediaServerMovie { @@ -56,7 +44,7 @@ pub fn plex_video_to_movie( item_id: Arc::clone(&rating_key), title, year: video.year, - release_date: non_blank(video.originally_available_at.as_deref()), + release_date: non_blank_arc(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), @@ -72,8 +60,8 @@ pub fn plex_directory_to_series( 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")); + let rating_key = non_blank_arc(directory.rating_key.as_deref())?; + let title = non_blank_arc(directory.title.as_deref()).unwrap_or_else(|| Arc::::from("Plex Series")); Some(MediaServerSeries { input_name: Arc::clone(input_name), @@ -82,7 +70,7 @@ pub fn plex_directory_to_series( item_id: Arc::clone(&rating_key), title, year: directory.year, - release_date: non_blank(directory.originally_available_at.as_deref()), + release_date: non_blank_arc(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), @@ -103,20 +91,20 @@ pub fn plex_directory_to_season( 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")); + let rating_key = non_blank_arc(directory.rating_key.as_deref())?; + let title = non_blank_arc(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()), + series_id: non_blank_arc(directory.parent_rating_key.as_deref()), + series_title: non_blank_arc(directory.parent_title.as_deref()), title, season: directory.index, year: directory.year, - release_date: non_blank(directory.originally_available_at.as_deref()), + release_date: non_blank_arc(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), @@ -131,8 +119,8 @@ pub fn plex_video_to_episode( 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 rating_key = non_blank_arc(video.rating_key.as_deref())?; + let title = non_blank_arc(video.title.as_deref()).unwrap_or_else(|| Arc::::from("Plex Episode")); let media = first_media(&video.media); Some(MediaServerEpisode { @@ -140,12 +128,12 @@ pub fn plex_video_to_episode( 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()), + series_id: non_blank_arc(video.grandparent_rating_key.as_deref()), + series_title: non_blank_arc(video.grandparent_title.as_deref()), title, season: video.parent_index, episode: video.index, - release_date: non_blank(video.originally_available_at.as_deref()), + release_date: non_blank_arc(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), @@ -155,13 +143,12 @@ pub fn plex_video_to_episode( }) } -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, - } +impl MediaServerLibraryCandidate for PlexSectionDto { + fn library_key(&self) -> Option<&str> { self.key.as_deref() } + + fn library_title(&self) -> Option<&str> { self.title.as_deref() } + + fn library_kind(&self) -> MediaServerLibraryKind { plex_library_kind(self.section_type.as_deref()) } } fn plex_library_kind(section_type: Option<&str>) -> MediaServerLibraryKind { @@ -172,10 +159,6 @@ fn plex_library_kind(section_type: Option<&str>) -> MediaServerLibraryKind { } } -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() @@ -220,7 +203,7 @@ fn plex_stream_ref( } fn first_part_key(media: &PlexMediaDto) -> Option> { - media.parts.iter().find_map(|part| non_blank(part.key.as_deref())) + media.parts.iter().find_map(|part| non_blank_arc(part.key.as_deref())) } fn plex_image_ref( @@ -229,7 +212,7 @@ fn plex_image_ref( rating_key: &Arc, candidates: [&Option; N], ) -> Option { - let image_path = candidates.into_iter().find_map(|candidate| non_blank(candidate.as_deref()))?; + let image_path = candidates.into_iter().find_map(|candidate| non_blank_arc(candidate.as_deref()))?; Some(MediaServerImageRef::Plex { input_name: Arc::clone(input_name), server_id: Arc::clone(server_id), @@ -240,14 +223,14 @@ fn plex_image_ref( 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()), + original_title: non_blank_arc(video.original_title.as_deref()), + sort_title: non_blank_arc(video.title_sort.as_deref()), + summary: non_blank_arc(video.summary.as_deref()), + tagline: non_blank_arc(video.tagline.as_deref()), + studio: non_blank_arc(video.studio.as_deref()), + content_rating: non_blank_arc(video.content_rating.as_deref()), parental_age: video.content_rating_age, - audience_rating: non_blank(video.audience_rating.as_deref()), + audience_rating: non_blank_arc(video.audience_rating.as_deref()), genres: tag_values(&video.genres), countries: tag_values(&video.countries), directors: tag_values(&video.directors), @@ -255,81 +238,49 @@ fn video_descriptive_facts(video: &PlexVideoDto) -> Option 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()), + original_title: non_blank_arc(directory.original_title.as_deref()), + sort_title: non_blank_arc(directory.title_sort.as_deref()), + summary: non_blank_arc(directory.summary.as_deref()), + tagline: non_blank_arc(directory.tagline.as_deref()), + studio: non_blank_arc(directory.studio.as_deref()), + content_rating: non_blank_arc(directory.content_rating.as_deref()), parental_age: directory.content_rating_age, - audience_rating: non_blank(directory.audience_rating.as_deref()), + audience_rating: non_blank_arc(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() + (!facts.is_empty()).then_some(facts) } fn media_technical_facts(media: &PlexMediaDto) -> Option { let video = MediaServerVideoTechnicalFacts { - codec: non_blank(media.video_codec.as_deref()), + codec: non_blank_arc(media.video_codec.as_deref()), width: media.width, height: media.height, }; let audio = MediaServerAudioTechnicalFacts { - codec: non_blank(media.audio_codec.as_deref()), + codec: non_blank_arc(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)), + container: non_blank_arc(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), + video: (!video.is_empty()).then_some(video), + audio: (!audio.is_empty()).then_some(audio), }; - has_technical_facts(&facts).then_some(facts) + (!facts.is_empty()).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() + media.parts.iter().find_map(|part| non_blank_arc(part.container.as_deref())) } fn plex_duration_ms_to_secs(duration: Option) -> Option { @@ -382,10 +333,6 @@ fn tag_values(tags: &[crate::plex::dto::PlexTagDto]) -> Vec> { tags.iter().filter_map(crate::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())) } diff --git a/backend/media-server/src/redaction.rs b/backend/media-server/src/redaction.rs index 1a3540dba..adf4ae942 100644 --- a/backend/media-server/src/redaction.rs +++ b/backend/media-server/src/redaction.rs @@ -21,6 +21,9 @@ const SENSITIVE_QUERY_KEYS: &[&str] = &[ const SENSITIVE_HEADER_NAMES: &[&str] = &["authorization", "x-emby-token", "x-mediabrowser-token", "x-plex-token", "cookie", "set-cookie"]; +/// `Some("***")` for a present secret, `None` otherwise: lets `Debug` impls show whether a secret is set. +pub(crate) fn redacted_secret(secret: Option<&str>) -> Option<&'static str> { secret.map(|_| REDACTED_VALUE) } + pub fn is_sensitive_media_server_header(name: &HeaderName) -> bool { let value = name.as_str().to_ascii_lowercase(); SENSITIVE_HEADER_NAMES.iter().any(|sensitive| value == *sensitive) diff --git a/backend/media-server/src/test_fixtures.rs b/backend/media-server/src/test_fixtures.rs index 66b113dad..d817cc9f5 100644 --- a/backend/media-server/src/test_fixtures.rs +++ b/backend/media-server/src/test_fixtures.rs @@ -37,6 +37,283 @@ pub const JELLYFIN_VIEWS_JSON: &str = r#" } "#; +pub const EMBY_MOVIES_PAGE_JSON: &str = r#" +{ + "Items": [ + { + "Id": "movie-redacted-1", + "Name": "Movie Redacted", + "OriginalTitle": "Movie Redacted Original", + "SortName": "movie redacted", + "Type": "Movie", + "ProductionYear": 2024, + "Overview": "Redacted overview text.", + "Taglines": ["Redacted tagline"], + "Genres": ["Action", "Drama"], + "Studios": [{ "Name": "Studio Redacted", "Id": "studio-redacted-1" }], + "People": [ + { "Name": "Actor Redacted", "Id": "person-redacted-1", "Role": "Role Redacted", "Type": "Actor" }, + { "Name": "Director Redacted", "Id": "person-redacted-2", "Type": "Director" } + ], + "OfficialRating": "PG-13", + "CommunityRating": 7.4, + "PremiereDate": "2024-03-01T00:00:00.0000000Z", + "DateCreated": "2024-04-02T10:15:30.1234567Z", + "ProductionLocations": ["Redacted Country"], + "RunTimeTicks": 72000000000, + "LocationType": "FileSystem", + "ParentId": "library-redacted-movies", + "ProviderIds": { "Tmdb": "12345", "Imdb": "tt0000001" }, + "ImageTags": { "Primary": "image-tag-redacted", "Logo": "logo-tag-redacted" }, + "BackdropImageTags": ["backdrop-tag-redacted"], + "Path": "/redacted/upstream/path/movie.mkv", + "UserData": { "Played": false, "PlaybackPositionTicks": 0 }, + "MediaSources": [ + { + "Id": "media-source-redacted-1", + "Protocol": "File", + "IsRemote": false, + "Container": "mkv", + "Path": "/redacted/upstream/path/movie.mkv", + "Bitrate": 8000000, + "RunTimeTicks": 72000000000, + "SupportsDirectPlay": true, + "SupportsDirectStream": true, + "MediaStreams": [ + { "Type": "Video", "Codec": "h264", "Width": 1920, "Height": 1080, "Index": 0 }, + { "Type": "Audio", "Codec": "aac", "Channels": 6, "Index": 1 }, + { "Type": "Subtitle", "Codec": "srt", "Index": 2 } + ] + } + ] + }, + { + "Id": "movie-redacted-2", + "Name": "Minimal Movie Redacted", + "Type": "Movie" + } + ], + "TotalRecordCount": 2, + "StartIndex": 0 +} +"#; + +pub const EMBY_SERIES_PAGE_JSON: &str = r#" +{ + "Items": [ + { + "Id": "series-redacted-1", + "Name": "Series Redacted", + "OriginalTitle": "Series Redacted Original", + "SortName": "series redacted", + "Type": "Series", + "ProductionYear": 2020, + "Overview": "Redacted series overview.", + "Genres": ["Drama"], + "Studios": [{ "Name": "Network Redacted", "Id": "studio-redacted-2" }], + "People": [{ "Name": "Actor Redacted", "Id": "person-redacted-1", "Type": "Actor" }], + "OfficialRating": "TV-14", + "CommunityRating": 8.1, + "PremiereDate": "2020-01-10T00:00:00.0000000Z", + "DateCreated": "2024-04-02T10:15:30.1234567Z", + "ChildCount": 2, + "RecursiveItemCount": 14, + "LocationType": "FileSystem", + "ParentId": "library-redacted-tv", + "ProviderIds": { "Tvdb": "67890", "Tmdb": "54321" }, + "ImageTags": { "Primary": "image-tag-redacted" }, + "Path": "/redacted/upstream/path/series" + } + ], + "TotalRecordCount": 1, + "StartIndex": 0 +} +"#; + +pub const EMBY_SEASONS_PAGE_JSON: &str = r#" +{ + "Items": [ + { + "Id": "season-redacted-1", + "Name": "Season 1", + "Type": "Season", + "IndexNumber": 1, + "SeriesId": "series-redacted-1", + "SeriesName": "Series Redacted", + "ParentId": "series-redacted-1", + "ChildCount": 8, + "RecursiveItemCount": 8, + "PremiereDate": "2020-01-10T00:00:00.0000000Z", + "LocationType": "FileSystem", + "ImageTags": { "Primary": "image-tag-redacted" } + }, + { + "Id": "season-redacted-2", + "Name": "Season 2", + "Type": "Season", + "IndexNumber": 2, + "SeriesId": "series-redacted-1", + "SeriesName": "Series Redacted", + "ParentId": "series-redacted-1", + "ChildCount": 6, + "RecursiveItemCount": 6, + "LocationType": "FileSystem" + } + ], + "TotalRecordCount": 2, + "StartIndex": 0 +} +"#; + +pub const EMBY_EPISODES_PAGE_JSON: &str = r#" +{ + "Items": [ + { + "Id": "episode-redacted-1", + "Name": "Episode Redacted One", + "Type": "Episode", + "IndexNumber": 1, + "ParentIndexNumber": 1, + "SeriesId": "series-redacted-1", + "SeriesName": "Series Redacted", + "SeasonId": "season-redacted-1", + "ParentId": "season-redacted-1", + "Overview": "Redacted episode overview.", + "PremiereDate": "2020-01-10T00:00:00.0000000Z", + "DateCreated": "2024-04-02T10:15:30.1234567Z", + "RunTimeTicks": 27000000000, + "LocationType": "FileSystem", + "ProviderIds": { "Tvdb": "11111" }, + "ImageTags": { "Primary": "image-tag-redacted" }, + "Path": "/redacted/upstream/path/series/s01e01.mkv", + "UserData": { "Played": true, "PlaybackPositionTicks": 0 }, + "MediaSources": [ + { + "Id": "media-source-redacted-2", + "Protocol": "File", + "IsRemote": false, + "Container": "mkv", + "Path": "/redacted/upstream/path/series/s01e01.mkv", + "Bitrate": 4000000, + "RunTimeTicks": 27000000000, + "SupportsDirectPlay": true, + "SupportsDirectStream": true, + "MediaStreams": [ + { "Type": "Video", "Codec": "hevc", "Width": 1280, "Height": 720, "Index": 0 }, + { "Type": "Audio", "Codec": "ac3", "Channels": 2, "Index": 1 } + ] + } + ] + }, + { + "Id": "episode-redacted-2", + "Name": "Episode Redacted Two (missing)", + "Type": "Episode", + "IndexNumber": 2, + "ParentIndexNumber": 1, + "SeriesId": "series-redacted-1", + "SeriesName": "Series Redacted", + "SeasonId": "season-redacted-1", + "ParentId": "season-redacted-1", + "PremiereDate": "2020-01-17T00:00:00.0000000Z", + "LocationType": "Virtual" + }, + { + "Id": "episode-redacted-3", + "Name": "Episode Redacted Three (remote)", + "Type": "Episode", + "IndexNumber": 3, + "ParentIndexNumber": 1, + "SeriesId": "series-redacted-1", + "SeriesName": "Series Redacted", + "SeasonId": "season-redacted-1", + "ParentId": "season-redacted-1", + "RunTimeTicks": 27000000000, + "LocationType": "FileSystem", + "Path": "/redacted/upstream/path/series/s01e03.strm", + "MediaSources": [ + { + "Id": "media-source-redacted-3", + "Protocol": "Http", + "IsRemote": true, + "Container": "strm", + "Path": "http://redacted.invalid/stream/redacted.ts", + "SupportsDirectPlay": true, + "SupportsDirectStream": true, + "MediaStreams": [ + { "Type": "Video", "Codec": "h264", "Width": 1920, "Height": 1080, "Index": 0 } + ] + } + ] + } + ], + "TotalRecordCount": 3, + "StartIndex": 0 +} +"#; + +pub const EMBY_VIRTUAL_FOLDERS_JSON: &str = r#" +[ + { + "Name": "Movies", + "Locations": ["/redacted/media/movies"], + "CollectionType": "movies", + "ItemId": "library-redacted-movies", + "Id": "library-redacted-movies-guid" + }, + { + "Name": "TV", + "Locations": ["/redacted/media/tv"], + "CollectionType": "tvshows", + "ItemId": "library-redacted-tv", + "Id": "library-redacted-tv-guid" + }, + { + "Name": "Mixed", + "Locations": ["/redacted/media/mixed"], + "ItemId": "library-redacted-mixed", + "Id": "library-redacted-mixed-guid" + }, + { + "Name": "Collections", + "Locations": [], + "CollectionType": "boxsets", + "ItemId": "library-redacted-boxsets", + "Id": "library-redacted-boxsets-guid" + } +] +"#; + +pub const EMBY_PUBLIC_SYSTEM_INFO_JSON: &str = r#" +{ + "LocalAddress": "http://192.0.2.10:8096", + "ServerName": "Server Redacted", + "Version": "4.8.0.0", + "ProductName": "Emby Server", + "OperatingSystem": "Linux", + "Id": "server-redacted-id", + "StartupWizardCompleted": true +} +"#; + +pub const EMBY_AUTH_RESULT_JSON: &str = r#" +{ + "User": { + "Name": "user-redacted", + "ServerId": "server-redacted-id", + "Id": "user-redacted-id", + "HasPassword": true + }, + "SessionInfo": { + "Id": "session-redacted", + "UserId": "user-redacted-id", + "DeviceName": "Tuliprox" + }, + "AccessToken": "token-redacted", + "ServerId": "server-redacted-id" +} +"#; + pub const PLEX_RESOURCES_XML: &str = r#" diff --git a/backend/media-server/src/test_server.rs b/backend/media-server/src/test_server.rs new file mode 100644 index 000000000..b7cdf490f --- /dev/null +++ b/backend/media-server/src/test_server.rs @@ -0,0 +1,38 @@ +use bytes::Bytes; +use http_body_util::Full; +use hyper::{body::Incoming, service::service_fn, Request, Response, StatusCode}; +use hyper_util::{ + rt::{TokioExecutor, TokioIo}, + server::conn::auto::Builder, +}; +use std::{convert::Infallible, future::Future}; +use tokio::{net::TcpListener, task::JoinHandle}; + +/// Starts a local HTTP server on `127.0.0.1:0` that answers every request with +/// `handler`. Returns the `http://host:port` base URL and the accept-loop task; +/// callers abort the task when the test is done. +pub(crate) async fn start_test_server(handler: H) -> (String, JoinHandle<()>) +where + H: Fn(Request) -> F + Clone + Send + Sync + 'static, + F: Future>, Infallible>> + Send + 'static, +{ + 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 handler = handler.clone(); + tokio::spawn(async move { + let io = TokioIo::new(socket); + let service = service_fn(handler); + let builder = Builder::new(TokioExecutor::new()); + let _ = builder.serve_connection(io, service).await; + }); + } + }); + (format!("http://{addr}"), handle) +} + +pub(crate) fn test_response(status: StatusCode, body: &'static [u8]) -> Response> { + Response::builder().status(status).body(Full::new(Bytes::from_static(body))).expect("response builds") +} diff --git a/backend/media-server/src/types.rs b/backend/media-server/src/types.rs index aaacb2755..04801d37c 100644 --- a/backend/media-server/src/types.rs +++ b/backend/media-server/src/types.rs @@ -144,6 +144,12 @@ pub struct MediaServerDescriptiveFacts { pub crew: Vec>, } +impl MediaServerDescriptiveFacts { + /// `true` when no field carries a value. + #[must_use] + pub fn is_empty(&self) -> bool { self == &Self::default() } +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct MediaServerVideoTechnicalFacts { pub codec: Option>, @@ -151,12 +157,24 @@ pub struct MediaServerVideoTechnicalFacts { pub height: Option, } +impl MediaServerVideoTechnicalFacts { + /// `true` when no field carries a value. + #[must_use] + pub fn is_empty(&self) -> bool { self == &Self::default() } +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct MediaServerAudioTechnicalFacts { pub codec: Option>, pub channels: Option, } +impl MediaServerAudioTechnicalFacts { + /// `true` when no field carries a value. + #[must_use] + pub fn is_empty(&self) -> bool { self == &Self::default() } +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct MediaServerTechnicalFacts { pub container: Option>, @@ -167,6 +185,12 @@ pub struct MediaServerTechnicalFacts { pub audio: Option, } +impl MediaServerTechnicalFacts { + /// `true` when no field carries a value. + #[must_use] + pub fn is_empty(&self) -> bool { self == &Self::default() } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MediaServerMovie { pub input_name: Arc, @@ -271,19 +295,6 @@ pub enum MediaServerImageRef { }, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MediaServerPlaybackLease { - pub provider_kind: MediaServerKind, - pub lease_id: Arc, -} - -#[derive(Debug, Clone)] -pub struct MediaServerResourceResponse { - pub status: StatusCode, - pub headers: HeaderMap, - pub body: Bytes, -} - pub type BoxedMediaServerStream = BoxStream<'static, Result>; pub struct MediaServerStreamResponse { @@ -306,6 +317,25 @@ impl fmt::Debug for MediaServerStreamResponse { mod tests { use super::*; + #[test] + fn facts_are_empty_only_when_every_field_is_unset() { + assert!(MediaServerDescriptiveFacts::default().is_empty()); + assert!(MediaServerTechnicalFacts::default().is_empty()); + assert!(MediaServerVideoTechnicalFacts::default().is_empty()); + assert!(MediaServerAudioTechnicalFacts::default().is_empty()); + + assert!(!MediaServerDescriptiveFacts { parental_age: Some(12), ..Default::default() }.is_empty()); + assert!(!MediaServerDescriptiveFacts { crew: vec!["crew".into()], ..Default::default() }.is_empty()); + assert!(!MediaServerTechnicalFacts { bitrate: Some(1), ..Default::default() }.is_empty()); + assert!(!MediaServerTechnicalFacts { + audio: Some(MediaServerAudioTechnicalFacts::default()), + ..Default::default() + } + .is_empty()); + assert!(!MediaServerVideoTechnicalFacts { height: Some(1), ..Default::default() }.is_empty()); + assert!(!MediaServerAudioTechnicalFacts { channels: Some(2), ..Default::default() }.is_empty()); + } + #[test] fn media_server_page_next_request_advances_until_total() { let page = MediaServerPage::new(MediaServerPageRequest::new(0, 2), Some(3), vec![1, 2]); diff --git a/backend/processing/src/processor/playlist/ingest/acquisition.rs b/backend/processing/src/processor/playlist/ingest/acquisition.rs index f1c7d8fda..9e5e71c55 100644 --- a/backend/processing/src/processor/playlist/ingest/acquisition.rs +++ b/backend/processing/src/processor/playlist/ingest/acquisition.rs @@ -9,8 +9,8 @@ use super::{ telemetry::{ finalize_input_telemetry, input_telemetry_for_fetch, is_cluster_input, persist_finalized_cluster_snapshots, }, - CacheStatusScope, InputDownloadResult, LibraryProvider, PendingInputStatusUpdate, PlaylistDownloadResult, - PlaylistProcessingContext, PlaylistUpdateExecutionRef, PlexProvider, StalkerProvider, + CacheStatusScope, InputDownloadResult, LibraryProvider, MediaServerProvider, PendingInputStatusUpdate, + PlaylistDownloadResult, PlaylistProcessingContext, PlaylistUpdateExecutionRef, StalkerProvider, PIPELINE_TRANSPARENCY_CLUSTERS, }; use crate::{input_cache, metadata_sink::MetadataUpdateSink, processor::StalkerRefreshMode}; @@ -171,15 +171,7 @@ async fn playlist_download_from_input( StalkerProvider::new(stalker_refresh_mode, !config.disk_based_processing).fetch(&request).await } InputType::Library => LibraryProvider.fetch(&request).await, - InputType::Plex => PlexProvider.fetch(&request).await, - InputType::Emby | InputType::Jellyfin => { - UnsupportedProvider::new( - "media-server", - format!("media-server input '{}' is configured but catalog import is not implemented yet", input.name), - ) - .fetch(&request) - .await - } + InputType::Plex | InputType::Emby | InputType::Jellyfin => MediaServerProvider.fetch(&request).await, InputType::Staged => { UnsupportedProvider::new( "staged", diff --git a/backend/processing/src/processor/playlist/mod.rs b/backend/processing/src/processor/playlist/mod.rs index 7f0891f5d..4bd9cf38e 100644 --- a/backend/processing/src/processor/playlist/mod.rs +++ b/backend/processing/src/processor/playlist/mod.rs @@ -1,4 +1,4 @@ -use super::providers::{LibraryProvider, PlexProvider, StalkerProvider, XmltvEpgProvider}; +use super::providers::{LibraryProvider, MediaServerProvider, StalkerProvider, XmltvEpgProvider}; use crate::{ fetched_playlist::FetchedPlaylist, input_cache, diff --git a/backend/processing/src/processor/providers.rs b/backend/processing/src/processor/providers.rs index 786994e12..de9fb2f56 100644 --- a/backend/processing/src/processor/providers.rs +++ b/backend/processing/src/processor/providers.rs @@ -1,8 +1,8 @@ //! The [`PlaylistProvider`] implementations whose orchestration lives in this crate. //! //! The M3U and Xtream providers ship with the client code in `tuliprox-iptv`. These three -//! cannot: Stalker's resumable refresh, the local library scan and the Plex catalog import -//! all reach into `tuliprox-repository` and this crate's own processors, and `tuliprox-iptv` +//! cannot: Stalker's resumable refresh, the local library scan and the media-server catalog +//! import (Plex, Emby, Jellyfin) all reach into `tuliprox-repository` and this crate's own processors, and `tuliprox-iptv` //! sits below both. The trait is what lets them keep that dependency direction and still //! answer the dispatcher in one shape. @@ -67,20 +67,20 @@ impl PlaylistProvider for LibraryProvider { } } -/// Plex media server catalog import. +/// Media server catalog import for Plex, Emby and Jellyfin inputs. #[derive(Debug, Clone, Copy, Default)] -pub struct PlexProvider; +pub struct MediaServerProvider; -impl PlaylistProvider for PlexProvider { - fn name(&self) -> &'static str { "plex" } +impl PlaylistProvider for MediaServerProvider { + fn name(&self) -> &'static str { "media-server" } async fn fetch(&self, request: &PlaylistFetchRequest<'_>) -> PlaylistFetch { - let (groups, errors) = download_plex_media_server_playlist(request.client, request.input).await; + let (groups, errors) = download_media_server_playlist(request.client, request.input).await; PlaylistFetch::groups(groups).with_errors(errors) } } -async fn download_plex_media_server_playlist( +async fn download_media_server_playlist( client: &reqwest::Client, input: &ConfigInput, ) -> (Vec, Vec) { @@ -94,7 +94,7 @@ async fn download_plex_media_server_playlist( ); }; let http_client = MediaServerHttpClient::new(client.clone()); - let plex_client = match input.plex_catalog_client(http_client) { + let catalog_client = match input.media_server_catalog_client(http_client) { Ok(client) => client, Err(error) => return (vec![], vec![TuliproxError::Download(error.to_string())]), }; @@ -103,7 +103,7 @@ async fn download_plex_media_server_playlist( request_delay_ms: media_server.catalog.request_delay_ms, }; - match refresh_media_server_catalog_complete_before_publish(&plex_client, policy).await { + match refresh_media_server_catalog_complete_before_publish(&catalog_client, policy).await { Ok(snapshot) => (media_server_catalog_snapshot_to_playlist(&snapshot), vec![]), Err(error) => (vec![], vec![TuliproxError::Download(error.to_string())]), } @@ -154,3 +154,31 @@ impl EpgProvider for Xmlt Ok(guide.map_or(EpgOutcome::NotConfigured, EpgOutcome::Guide)) } } + +#[cfg(test)] +mod tests { + use super::{download_media_server_playlist, MediaServerProvider}; + use shared::{error::ErrorKind, model::InputType}; + use tuliprox_core::model::ConfigInput; + use tuliprox_iptv::provider::PlaylistProvider; + + #[tokio::test] + async fn media_server_inputs_without_media_server_block_report_download_error() { + let client = reqwest::Client::new(); + for input_type in [InputType::Plex, InputType::Emby, InputType::Jellyfin] { + let input = ConfigInput { name: "ms".into(), input_type, media_server: None, ..ConfigInput::default() }; + + let (groups, errors) = download_media_server_playlist(&client, &input).await; + + assert!(groups.is_empty()); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].kind(), ErrorKind::Download, "{input_type:?}"); + assert!(errors[0].message().contains("media_server"), "{}", errors[0].message()); + } + } + + #[test] + fn media_server_provider_is_named_media_server() { + assert_eq!(MediaServerProvider.name(), "media-server"); + } +} diff --git a/docs/src/configuration/source.md b/docs/src/configuration/source.md index 466405de2..1dc455560 100644 --- a/docs/src/configuration/source.md +++ b/docs/src/configuration/source.md @@ -127,23 +127,24 @@ inputs: ### Input Base Parameters -| Parameter | Type | Required | Default | Technical Impact & Background | -|:------------------------|:-------|:--------:|:--------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `name` | String | Yes | | Internal reference ID for Tuliprox. Must be strictly unique. Critical for persistent UUID generation! | -| `type` | Enum | No | `m3u` | Allowed: `m3u`, `xtream`, `stalker`, `library`, `staged`, `emby`, `jellyfin`, `plex`, and `m3u_batch` / `xtream_batch` / `stalker_batch` (CSV offloading). Stalker inputs use the portal handshake/catalog flow instead of a plain playlist download. | -| `url` | String | Yes | | The Provider URL. Tuliprox supports magic scheme prefixes: `http(s)://`, `file://`, `batch://`, and **`provider://my_failover_provider`** (for the Failover System above). | -| `username` / `password` | String | Often | | Mandatory for `xtream` and for Stalker inputs using `credentials_only` or `mac_plus_credentials`. Stalker inputs using `mac_only` do not need them. | -| `enabled` | Bool | No | `true` | If `false`, this input is completely ignored in all processing. | -| `sequential_group` | Int | No | | Optional non-zero process-wide group ID. With `process_parallel: true`, complete refreshes of inputs sharing an ID run one after another, including all Stalker page slices. Different groups and ungrouped inputs may overlap. Not valid for `staged` inputs; a staged overlay inherits its provider job. | -| `cache_duration` | String | No | `0` | **Crucial:** Determines how often Tuliprox actually downloads the raw list from the provider. At `1d` (1 day), Tuliprox serves from its local `.db` for 24 hours, even if you trigger hourly updates. This heavily protects against provider bans! Supported units are `s`, `m`, `h`, and `d`. If `cache_duration` is set, the cached provider playlist stored on disk is reused for subsequent updates instead of downloading it again. | -| `persist` | String | No | | Optional path template (e.g., `./playlist_{}.m3u`) to permanently store the downloaded raw provider list locally on your disk. The `{}` in the filename is filled with the current timestamp. For `m3u` use a full filename. For `xtream` use a prefix like `./playlist_`. | -| `method` | Enum | No | `GET` | HTTP Request method for playlist downloads (`GET` or `POST`). | -| `exp_date` | Mixed | No | | Expiration date as `"YYYY-MM-DD HH:MM:SS"` or Unix timestamp. In server mode, Tuliprox refreshes missing or soon-expiring Xtream account dates through the account's `player_api.php` credentials; see [Automatic Xtream Expiration Refresh](#automatic-xtream-expiration-refresh). | -| `headers` | Dict | No | | Custom HTTP headers for the download (e.g., `User-Agent: My-Player`). | -| `epg` | Object | No | | Allows mapping of external XMLTV files (see [below](#input-subsections-object-keys)). | -| `aliases` | List | No | | Connection pooling / Sub-accounts (see [below](#input-subsections-object-keys)). | -| `staged` | Object | No | | Staged overlay settings. Only valid when `type: staged` (see [below](#input-subsections-object-keys)). | -| `panel_api` | Object | No | | Automated reseller account generation (see [below](#input-subsections-object-keys)). | +| Parameter | Type | Required | Default | Technical Impact & Background | +|:------------------------|:-------|:--------:|:--------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `name` | String | Yes | | Internal reference ID for Tuliprox. Must be strictly unique. Critical for persistent UUID generation! | +| `type` | Enum | No | `m3u` | Allowed: `m3u`, `xtream`, `stalker`, `library`, `staged`, `emby`, `jellyfin`, `plex` (see [Media server inputs](#27-media-server-inputs-plex-emby-jellyfin)), and `m3u_batch` / `xtream_batch` / `stalker_batch` (CSV offloading). Stalker inputs use the portal handshake/catalog flow instead of a plain playlist download. | +| `url` | String | Yes | | The Provider URL. Tuliprox supports magic scheme prefixes: `http(s)://`, `file://`, `batch://`, and **`provider://my_failover_provider`** (for the Failover System above). | +| `username` / `password` | String | Often | | Mandatory for `xtream` and for Stalker inputs using `credentials_only` or `mac_plus_credentials`. Stalker inputs using `mac_only` do not need them. `emby` and `jellyfin` inputs may use them instead of a token (login bootstrap). | +| `enabled` | Bool | No | `true` | If `false`, this input is completely ignored in all processing. | +| `sequential_group` | Int | No | | Optional non-zero process-wide group ID. With `process_parallel: true`, complete refreshes of inputs sharing an ID run one after another, including all Stalker page slices. Different groups and ungrouped inputs may overlap. Not valid for `staged` inputs; a staged overlay inherits its provider job. | +| `cache_duration` | String | No | `0` | **Crucial:** Determines how often Tuliprox actually downloads the raw list from the provider. At `1d` (1 day), Tuliprox serves from its local `.db` for 24 hours, even if you trigger hourly updates. This heavily protects against provider bans! Supported units are `s`, `m`, `h`, and `d`. If `cache_duration` is set, the cached provider playlist stored on disk is reused for subsequent updates instead of downloading it again. | +| `persist` | String | No | | Optional path template (e.g., `./playlist_{}.m3u`) to permanently store the downloaded raw provider list locally on your disk. The `{}` in the filename is filled with the current timestamp. For `m3u` use a full filename. For `xtream` use a prefix like `./playlist_`. | +| `method` | Enum | No | `GET` | HTTP Request method for playlist downloads (`GET` or `POST`). | +| `exp_date` | Mixed | No | | Expiration date as `"YYYY-MM-DD HH:MM:SS"` or Unix timestamp. In server mode, Tuliprox refreshes missing or soon-expiring Xtream account dates through the account's `player_api.php` credentials; see [Automatic Xtream Expiration Refresh](#automatic-xtream-expiration-refresh). | +| `headers` | Dict | No | | Custom HTTP headers for the download (e.g., `User-Agent: My-Player`). | +| `epg` | Object | No | | Allows mapping of external XMLTV files (see [below](#input-subsections-object-keys)). | +| `aliases` | List | No | | Connection pooling / Sub-accounts (see [below](#input-subsections-object-keys)). | +| `staged` | Object | No | | Staged overlay settings. Only valid when `type: staged` (see [below](#input-subsections-object-keys)). | +| `panel_api` | Object | No | | Automated reseller account generation (see [below](#input-subsections-object-keys)). | +| `media_server` | Object | Yes* | | Settings of `plex`, `emby` and `jellyfin` inputs: library selection, credentials and catalog options. *Mandatory for these input types only; see [Media server inputs](#27-media-server-inputs-plex-emby-jellyfin). | #### Minimal Stalker Input Example @@ -204,14 +205,15 @@ logic. ### Input Subsections (Object Keys) -| Block | Description | Link | -|:------------|:---------------------------------------------------------------------------|:---------------------------------------------------| -| `headers` | Custom HTTP request headers for playlist and EPG downloads. | [See Headers](#21-headers-headers) | -| `options` | Behavior controls for metadata resolution, stream probing, and skip logic. | [See Options](#22-input-options-options) | -| `epg` | XMLTV source management and Smart Match fuzzy logic settings. | [See EPG](#23-epg-assignment--smart-match-epg) | -| `aliases` | Connection pooling for multiple subscriptions from the same provider. | [See Aliases](#24-provider-aliases-aliases--batch) | -| `staged` | Overlay settings for first-class staged inputs. | [See Staged](#25-staged-sources-staged) | -| `panel_api` | Automated reseller panel integration (provisioning/renewal). | [See Panel API](#26-provider-panel-api-panel_api) | +| Block | Description | Link | +|:---------------|:--------------------------------------------------------------------------------------|:----------------------------------------------------------------------| +| `headers` | Custom HTTP request headers for playlist and EPG downloads. | [See Headers](#21-headers-headers) | +| `options` | Behavior controls for metadata resolution, stream probing, and skip logic. | [See Options](#22-input-options-options) | +| `epg` | XMLTV source management and Smart Match fuzzy logic settings. | [See EPG](#23-epg-assignment--smart-match-epg) | +| `aliases` | Connection pooling for multiple subscriptions from the same provider. | [See Aliases](#24-provider-aliases-aliases--batch) | +| `staged` | Overlay settings for first-class staged inputs. | [See Staged](#25-staged-sources-staged) | +| `panel_api` | Automated reseller panel integration (provisioning/renewal). | [See Panel API](#26-provider-panel-api-panel_api) | +| `media_server` | Library selection, credentials and catalog options of Plex, Emby and Jellyfin inputs. | [See Media server inputs](#27-media-server-inputs-plex-emby-jellyfin) | --- @@ -1199,6 +1201,213 @@ Tuliprox processes all Panel API responses as JSON and strictly requires `status --- +### 2.7 Media server inputs (`plex`, `emby`, `jellyfin`) + +Inputs of `type: plex`, `type: emby` and `type: jellyfin` import the movie and TV libraries you select from a media +server into the playlist, so they can be mapped, filtered and published through targets like any other input. The +server stays the owner of the files: Tuliprox does not copy media, it proxies **direct play** on demand. + +All three types share the `media_server` block and the rules below. A media server input must not use `aliases`, +`epg`, `panel_api` or `provider` failover definitions, and its `url` must not use the `batch://` or `provider://` +schemes. The `media_server` block and at least one entry in `media_server.libraries` are mandatory. + +#### The `media_server` block + +Unknown keys inside `media_server` are rejected while loading the configuration. + +| Field | Type | Required | Default | Description | +|:--------------------------------|:-------|:---------------|:------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------| +| `libraries` | List | Yes | | Libraries to import. Each entry is a library name or an object (see [Library selectors](#library-selectors)). Empty entries are rejected. | +| `token` | String | See below | | Plex: PMS token for direct access. Emby/Jellyfin: access token sent as `X-Emby-Token` (Emby) or in the `Authorization: MediaBrowser` header (Jellyfin). | +| `api_key` | String | See below | | Emby/Jellyfin only: server API key. It is sent exactly like `token` (never as a `?api_key=` query parameter). Ignored when `token` is set. | +| `user_id` | String | No | | Emby/Jellyfin only: user whose library views and items are listed. Required for non-admin tokens (see [Emby and Jellyfin](#emby-and-jellyfin)). | +| `account_token` | String | Plex, no `url` | | Plex only: plex.tv account token used to discover the server when `url` is omitted. | +| `server_id` | String | No | | Plex only: machine identifier of the server. With `url` it is verified against the server; without `url` it selects the server. | +| `server_name` | String | No | | Plex only: friendly name of the server, used like `server_id`. | +| `prefer_https` | Bool | No | `true` | Plex only: with account discovery, try `https` connections before `http` ones. | +| `allow_relay` | Bool | No | `false` | Plex only: with account discovery, allow plex.tv relay connections (tried last). | +| `catalog.page_size` | Int | No | `100` | Items requested per page. Must be greater than `0`. | +| `catalog.request_delay_ms` | Int | No | `250` | Pause in milliseconds between two catalog page requests, to keep the load on the server low. | +| `catalog.include_media_sources` | Bool | No | `true` | Emby/Jellyfin: request `MediaSources` to pick the file to play and to fill container, codec, bitrate and duration. See [Playback](#media-server-playback). | + +#### Credentials per server type + +Input-level `url`, `username` and `password` sit next to `media_server`, not inside it. + +##### Plex + +* **Direct access:** set `url` to the Plex Media Server and `media_server.token`. `server_id` and `server_name` + are optional checks against the server identity. +* **Account discovery:** omit `url` and set `media_server.account_token` plus `server_id` or `server_name`. The + server is looked up on plex.tv; `prefer_https` and `allow_relay` steer the connection choice. + +##### Emby and Jellyfin + +`url` is mandatory. The server URL may carry a path prefix, for example `https://media.example.invalid/jellyfin` or +`http://192.0.2.10:8096/emby`; the prefix is kept for every request, with or without a trailing slash. + +Credentials are resolved in this order, the first one present wins: + +1. `media_server.token` +2. `media_server.api_key` +3. Login with the input's `username` and `password` (`POST /Users/AuthenticateByName`). + +At least one of them is mandatory. A configured `token` or `api_key` is used as is; if the server rejects it +(`401`), the update fails with an authentication error. A token obtained by login is kept in a process-wide session +that is shared by every request of the same input, so concurrent playback and refreshes log in only once. If the +server invalidates that token (expired session, server restart), the next request logs in again once and is +retried. The device identity sent to the server is derived from the input name and user name, so a restart of +Tuliprox replaces its previous session on the server instead of adding a new device. + +| Server | How the token is sent | +|:---------|:---------------------------------------------------------------------------------------------------| +| Emby | `X-Emby-Token` header, with the client identity in `X-Emby-Authorization`. | +| Jellyfin | Only `Authorization: MediaBrowser Client=..., Device=..., DeviceId=..., Version=..., Token="..."`. | + +Jellyfin requests never carry `X-Emby-Token`, `X-MediaBrowser-Token`, `X-Emby-Authorization` or an `api_key` query +parameter. These legacy authentication methods are deprecated since Jellyfin 10.11 and removed in Jellyfin 12.0, so +imports keep working there. The token is never placed in a URL. + +`user_id` decides which library list is used: + +* **With a user** (`user_id` configured, or the user returned by the login): the libraries visible to that user are + listed and items are requested in the context of that user. A configured `user_id` takes precedence over the + user returned by the login. +* **Without a user** (`token` or `api_key` and no `user_id`): the server-wide library list + (`/Library/VirtualFolders`) is used. This requires an administrator token or API key. A non-admin token fails + with an authentication error; set `user_id` to the user that owns the token in that case. + +#### Library selectors + +`libraries` lists what to import. Each entry is either a plain library name or an object: + +```yaml +libraries: + - Movies # by name + - id: LIBRARY_ID # by library id (Emby/Jellyfin) ... + kind: tvshows + - key: "2" # ... or by section key (Plex) + kind: movies +``` + +* A plain string, or `name`, matches the library title exactly (case-sensitive, surrounding spaces ignored). +* `id` and `key` are interchangeable and match the library's stable identifier. If several of `id`, `key` and `name` + are given, a library matching any of them is selected. +* `kind` (`movies` or `tvshows`, aliases `shows` and `series`) additionally requires the library to be of that + kind. A library of another kind does not match. +* Every selector must resolve to exactly one library: no match fails the update (`selected library was not + visible`), several matches fail it (`selected library name was ambiguous`). Use `id` to disambiguate. + +Only libraries of the types **movies** (`movies` on Emby/Jellyfin, `movie` on Plex) and **TV shows** (`tvshows`, +`show`) are imported. A selected library of any other type, such as `mixed`, `boxsets`, `music` or `homevideos`, is +skipped and nothing is imported from it. Emby/Jellyfin collections (BoxSets) are not imported as collections; their +members appear through their own library. + +#### What is imported + +For Emby and Jellyfin the import reads, per selected library: + +* **Movies** and, for TV libraries, **series**, **seasons** and **episodes**, with title, year, release date, + provider ids (TMDB, IMDb, TVDB), summary, genres, studio or network, content and audience rating, cast and crew, + and, when `catalog.include_media_sources` is enabled, container, codec, resolution, bitrate and duration. +* The **primary image** of every item that has one. + +Items are skipped or marked as follows: + +* Missing episodes and movies (`LocationType: Virtual`, items the server knows but has no file for) are not + imported. +* Items without a name are imported with a generic title such as `Jellyfin Movie`; items without an id are skipped. +* Items whose media sources are all remote (`Protocol` other than `File`, `IsRemote`, or `.strm` entries) are + imported but not playable: Tuliprox does not proxy them, which also prevents a server library that is fed by + Tuliprox's own STRM output from looping back into Tuliprox. Requesting such an item fails because no stream can be + opened ("no direct playable source" is logged). This detection needs the media source data, so it only applies + while `catalog.include_media_sources` is `true`. +* When `catalog.include_media_sources` is `true` and an item reports no media source at all, it is also imported as + not playable. + +An update reads all selected libraries completely before anything is published; an error while reading any of them +fails the input update instead of producing a partial catalog. + +#### Updating + +The input card in the **Update** tab of the Web UI offers **Update** and **Refresh** for Plex, Emby and Jellyfin +inputs. There is no **Force Update** for these inputs. An update contacts the server and reads all selected +libraries; to keep the load low, requests are paged by `catalog.page_size` with `catalog.request_delay_ms` between +pages. + +#### Media server playback + +Playlist entries of a media server input point to Tuliprox, never to the media server: + +* **Direct play proxy:** a play request is answered by streaming the original file from the server through + Tuliprox (Emby/Jellyfin: `/Videos/{id}/stream?static=true`, with the selected `mediaSourceId`). `Range` requests + are forwarded, so seeking works and the server's `206` status and `Content-Range` are passed on. Nothing is + transcoded. +* **Artwork:** posters are fetched from the server on demand (`Primary` image, using the image tag so changes are + picked up) and served by Tuliprox. +* **No credentials for clients:** the server URL, token, API key and password are never part of playlists, redirects + or responses. Only `Content-Type`, `Content-Length`, `Content-Range`, `Accept-Ranges`, `ETag` and `Last-Modified` + are passed from the server to the client. +* **Media source choice (Emby/Jellyfin):** the first local file source is used; a source that supports direct + play or direct stream is preferred. With `catalog.include_media_sources: false` no source is selected and the + server's default source is played. + +#### Examples + +Plex, direct access: + +```yaml +inputs: + - name: plex_home + type: plex + url: http://192.0.2.20:32400 + enabled: true + media_server: + token: YOUR_PMS_TOKEN + libraries: + - Movies + - name: TV Shows + kind: tvshows +``` + +Emby with an API key (server-wide library list, requires an administrator key): + +```yaml +inputs: + - name: emby_main + type: emby + url: http://192.0.2.10:8096/emby + enabled: true + media_server: + api_key: YOUR_EMBY_API_KEY + libraries: + - Movies + - Series +``` + +Jellyfin with a login, behind a reverse proxy with a path prefix: + +```yaml +inputs: + - name: jellyfin_main + type: jellyfin + url: https://media.example.invalid/jellyfin + username: tuliprox + password: YOUR_PASSWORD + enabled: true + media_server: + libraries: + - Movies + - name: Shows + kind: tvshows + catalog: + page_size: 200 +``` + +The login bootstraps the token and the user id; use a dedicated Jellyfin user that can see the selected libraries. + +--- + ## 3. Routing & Targets (`sources`) This block links your inputs to one or more output targets and defines how Tuliprox transforms, filters, sorts, and diff --git a/frontend/public/assets/i18n/ar.json b/frontend/public/assets/i18n/ar.json index 153ec6b4a..450c71ba9 100644 --- a/frontend/public/assets/i18n/ar.json +++ b/frontend/public/assets/i18n/ar.json @@ -2063,7 +2063,6 @@ "BASELINE": "المجموعة السابقة", "BULK_BUSY": "يوجد تحديث آخر لقائمة التشغيل قيد التشغيل أو الانتظار. لم يبدأ التحديث الشامل.", "BULK_ONLY_DESCRIPTION": "لا يتوفر تحديث منفرد لهذا الإدخال. استخدم «تحديث جميع الإدخالات».", - "PROVIDER_IMPORT_UNAVAILABLE": "استيراد المزود غير متاح", "CACHE_BYPASSED_FORCE": "تم تجاوزه بواسطة Force", "CACHE_BYPASSED_REFRESH": "تم تجاوزه بواسطة Refresh", "CACHE_NOT_USED": "لم يُستخدم (المزوّد)", diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 9210a59a3..8d583c785 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -2063,7 +2063,6 @@ "BASELINE": "Previous population", "BULK_BUSY": "Another playlist update is already running or queued. The bulk update was not started.", "BULK_ONLY_DESCRIPTION": "Single-input updates are not available for this input. Use ‘Update all inputs’.", - "PROVIDER_IMPORT_UNAVAILABLE": "Provider import not available", "CACHE_BYPASSED_FORCE": "Bypassed by Force", "CACHE_BYPASSED_REFRESH": "Bypassed by Refresh", "CACHE_NOT_USED": "Not used (provider)", diff --git a/frontend/public/assets/i18n/es.json b/frontend/public/assets/i18n/es.json index b54560abe..b75d465ea 100644 --- a/frontend/public/assets/i18n/es.json +++ b/frontend/public/assets/i18n/es.json @@ -2063,7 +2063,6 @@ "BASELINE": "Contenido anterior", "BULK_BUSY": "Ya hay otra actualización de lista en curso o en cola. La actualización en conjunto no se ha iniciado.", "BULK_ONLY_DESCRIPTION": "Esta entrada no admite actualizaciones individuales. Usa ‘Actualizar todas las entradas’.", - "PROVIDER_IMPORT_UNAVAILABLE": "Importación del proveedor no disponible", "CACHE_BYPASSED_FORCE": "Omitida por Forzar", "CACHE_BYPASSED_REFRESH": "Omitida por Recargar", "CACHE_NOT_USED": "No usada (proveedor)", diff --git a/frontend/public/assets/i18n/ru.json b/frontend/public/assets/i18n/ru.json index bb864e3f2..d8faac564 100644 --- a/frontend/public/assets/i18n/ru.json +++ b/frontend/public/assets/i18n/ru.json @@ -2063,7 +2063,6 @@ "BASELINE": "Предыдущий набор", "BULK_BUSY": "Другое обновление плейлиста уже выполняется или ожидает. Массовое обновление не запущено.", "BULK_ONLY_DESCRIPTION": "Отдельное обновление этого источника недоступно. Используйте «Обновить все источники».", - "PROVIDER_IMPORT_UNAVAILABLE": "Импорт провайдера недоступен", "CACHE_BYPASSED_FORCE": "Обойден режимом Force", "CACHE_BYPASSED_REFRESH": "Обойден режимом Refresh", "CACHE_NOT_USED": "Не использован (провайдер)", diff --git a/frontend/src/app/components/playlist/input_update_card.rs b/frontend/src/app/components/playlist/input_update_card.rs index 7860a9a7c..518e8e9af 100644 --- a/frontend/src/app/components/playlist/input_update_card.rs +++ b/frontend/src/app/components/playlist/input_update_card.rs @@ -1917,8 +1917,8 @@ mod tests { (InputType::Staged, &[], false), (InputType::Library, &["rescan"], true), (InputType::Plex, &[POLICY_NORMAL, POLICY_REFRESH], true), - (InputType::Jellyfin, &[], false), - (InputType::Emby, &[], false), + (InputType::Jellyfin, &[POLICY_NORMAL, POLICY_REFRESH], true), + (InputType::Emby, &[POLICY_NORMAL, POLICY_REFRESH], true), ]; for &(input_type, expected, starts_playlist_update) in cases { for enabled in [true, false] { diff --git a/frontend/src/app/components/playlist/input_update_card_integration_tests.rs b/frontend/src/app/components/playlist/input_update_card_integration_tests.rs index 3c05ed7f4..d9ad7886b 100644 --- a/frontend/src/app/components/playlist/input_update_card_integration_tests.rs +++ b/frontend/src/app/components/playlist/input_update_card_integration_tests.rs @@ -22,8 +22,8 @@ const INPUTS: [(u16, InputType, &[&str]); 11] = [ (33, InputType::Staged, &[]), (15, InputType::Library, &["rescan"]), (4, InputType::Plex, &["normal", "refresh"]), - (28, InputType::Jellyfin, &[]), - (9, InputType::Emby, &[]), + (28, InputType::Jellyfin, &["normal", "refresh"]), + (9, InputType::Emby, &["normal", "refresh"]), ]; fn sources() -> SourcesConfigDto { diff --git a/frontend/src/model/input_update_capabilities.rs b/frontend/src/model/input_update_capabilities.rs index a674742e0..6a2b31893 100644 --- a/frontend/src/model/input_update_capabilities.rs +++ b/frontend/src/model/input_update_capabilities.rs @@ -14,9 +14,7 @@ impl InputUpdateCapabilitiesExt for InputUpdateCapabilities { fn selected_action(self, policy: InputRefreshPolicy) -> InputUpdateAction { match self { Self::Rescan => InputUpdateAction::Rescan, - Self::Manual { .. } | Self::BulkOnly | Self::ImportUnavailable | Self::Disabled => { - InputUpdateAction::Provider(policy) - } + Self::Manual { .. } | Self::BulkOnly | Self::Disabled => InputUpdateAction::Provider(policy), } } @@ -40,7 +38,6 @@ impl InputUpdateCapabilitiesExt for InputUpdateCapabilities { } Self::Rescan => "MESSAGES.PLAYLIST_UPDATE.LIBRARY_DESCRIPTION", Self::BulkOnly => "MESSAGES.PLAYLIST_UPDATE.BULK_ONLY_DESCRIPTION", - Self::ImportUnavailable => "MESSAGES.PLAYLIST_UPDATE.PROVIDER_IMPORT_UNAVAILABLE", Self::Disabled => "MESSAGES.PLAYLIST_UPDATE.INPUT_DISABLED", } } @@ -82,11 +79,7 @@ mod tests { assert_eq!(capability.policies().len(), 0); assert!(capability.rebuilds_targets()); assert_eq!(capability.selected_action(InputRefreshPolicy::NORMAL), InputUpdateAction::Rescan); - for unavailable in [ - InputUpdateCapabilities::BulkOnly, - InputUpdateCapabilities::ImportUnavailable, - InputUpdateCapabilities::Disabled, - ] { + for unavailable in [InputUpdateCapabilities::BulkOnly, InputUpdateCapabilities::Disabled] { assert!(!unavailable.rebuilds_targets()); } } @@ -99,7 +92,7 @@ mod tests { include_str!("../../public/assets/i18n/ar.json"), ] { let locale: serde_json::Value = serde_json::from_str(source)?; - for key in ["LIBRARY_DESCRIPTION", "PROVIDER_IMPORT_UNAVAILABLE", "BULK_ONLY_DESCRIPTION"] { + for key in ["LIBRARY_DESCRIPTION", "BULK_ONLY_DESCRIPTION"] { assert!(locale["MESSAGES"]["PLAYLIST_UPDATE"][key].as_str().is_some_and(|text| !text.is_empty())); } for key in ["RESCAN", "START_RESCAN"] { diff --git a/frontend/src/model/input_update_card_model.rs b/frontend/src/model/input_update_card_model.rs index e3ecbf70b..288fad095 100644 --- a/frontend/src/model/input_update_card_model.rs +++ b/frontend/src/model/input_update_card_model.rs @@ -284,7 +284,7 @@ mod tests { .filter(|card| card.capabilities.supports(InputRefreshPolicy::NORMAL)) .map(|card| card.input_id) .collect::>(), - vec![1, 5, 6, 10] + vec![1, 5, 6, 10, 11, 12] ); } diff --git a/shared/src/model/config/media_server_catalog.rs b/shared/src/model/config/media_server_catalog.rs index d3ff5a419..9bae82246 100644 --- a/shared/src/model/config/media_server_catalog.rs +++ b/shared/src/model/config/media_server_catalog.rs @@ -8,25 +8,9 @@ use crate::{ }; use std::sync::Arc; -#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "lowercase")] -pub enum MediaServerCatalogRefreshMode { - #[default] - Manual, - Scheduled, -} - -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: MediaServerCatalogRefreshMode, - #[serde(default, skip_serializing_if = "is_false")] - pub refresh_on_startup: bool, #[serde( default = "default_media_server_catalog_page_size", skip_serializing_if = "is_default_media_server_catalog_page_size" @@ -39,22 +23,14 @@ pub struct MediaServerCatalogConfigDto { pub request_delay_ms: u64, #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub include_media_sources: bool, - #[serde(default, alias = "include_file_paths", skip_serializing_if = "is_false")] - pub include_paths: bool, - #[serde(default, skip_serializing_if = "is_false")] - pub include_user_state: bool, } impl Default for MediaServerCatalogConfigDto { fn default() -> Self { Self { - 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(), include_media_sources: default_as_true(), - include_paths: false, - include_user_state: false, } } } @@ -72,58 +48,6 @@ impl MediaServerCatalogConfigDto { } } -#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "snake_case")] -pub enum MediaServerPlaybackInfoPolicy { - #[default] - OnDemand, - Disabled, -} - -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: MediaServerPlaybackInfoPolicy, - #[serde(default, skip_serializing_if = "is_false")] - pub preflight_streams: bool, - #[serde(default = "default_as_true", skip_serializing_if = "is_true")] - pub direct_play_only: bool, - #[serde(default, skip_serializing_if = "is_false")] - pub allow_transcode: bool, -} - -impl Default for MediaServerPlaybackConfigDto { - fn default() -> Self { - Self { - playback_info_policy: MediaServerPlaybackInfoPolicy::default(), - preflight_streams: false, - direct_play_only: default_as_true(), - allow_transcode: false, - } - } -} - -impl MediaServerPlaybackConfigDto { - pub fn is_default(&self) -> bool { self == &Self::default() } -} - -#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "snake_case")] -pub enum MediaServerImagePolicy { - #[default] - ProxyOnDemand, - Disabled, -} - -pub fn is_default_media_server_image_policy(value: &MediaServerImagePolicy) -> bool { - *value == MediaServerImagePolicy::default() -} - #[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum MediaServerLibraryKind { @@ -199,10 +123,6 @@ pub struct MediaServerInputConfigDto { 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: MediaServerImagePolicy, #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub token: Option, #[serde(default, skip_serializing_if = "is_blank_optional_string")] @@ -226,8 +146,6 @@ impl Default for MediaServerInputConfigDto { Self { libraries: Vec::new(), catalog: MediaServerCatalogConfigDto::default(), - playback: MediaServerPlaybackConfigDto::default(), - image_policy: MediaServerImagePolicy::default(), token: None, api_key: None, user_id: None, diff --git a/shared/src/model/config/media_server_catalog/tests/catalog_auth.rs b/shared/src/model/config/media_server_catalog/tests/catalog_auth.rs index ab5fcda5c..876acd9b3 100644 --- a/shared/src/model/config/media_server_catalog/tests/catalog_auth.rs +++ b/shared/src/model/config/media_server_catalog/tests/catalog_auth.rs @@ -54,13 +54,6 @@ fn media_server_defaults_are_conservative() { assert_eq!(media_server.catalog.page_size, 100); assert_eq!(media_server.catalog.request_delay_ms, 250); assert!(media_server.catalog.include_media_sources); - assert!(!media_server.catalog.include_paths); - assert!(!media_server.catalog.include_user_state); - assert!(!media_server.catalog.refresh_on_startup); - 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, MediaServerImagePolicy::ProxyOnDemand); assert!(!media_server.allow_relay); } @@ -101,6 +94,24 @@ fn media_server_enrichment_block_is_not_part_of_schema() { assert!(err.to_string().contains("unknown field `enrichment`")); } +#[test] +fn removed_media_server_options_are_rejected() { + for (json, field) in [ + (r#"{"libraries":["Movies"],"playback":{"allow_transcode":true}}"#, "playback"), + (r#"{"libraries":["Movies"],"image_policy":"disabled"}"#, "image_policy"), + (r#"{"libraries":["Movies"],"catalog":{"refresh_mode":"scheduled"}}"#, "refresh_mode"), + (r#"{"libraries":["Movies"],"catalog":{"refresh_on_startup":true}}"#, "refresh_on_startup"), + (r#"{"libraries":["Movies"],"catalog":{"include_paths":true}}"#, "include_paths"), + (r#"{"libraries":["Movies"],"catalog":{"include_file_paths":true}}"#, "include_file_paths"), + (r#"{"libraries":["Movies"],"catalog":{"include_user_state":true}}"#, "include_user_state"), + ] { + let err = + serde_json::from_str::(json).expect_err("removed options must be rejected"); + + assert!(err.to_string().contains(&format!("unknown field `{field}`")), "{field}: {err}"); + } +} + #[test] fn prepare_accepts_emby_media_server_with_token_and_library() { let mut dto = ConfigInputDto { diff --git a/shared/src/model/input_update_action.rs b/shared/src/model/input_update_action.rs index ec10f4900..d5fcd8f06 100644 --- a/shared/src/model/input_update_action.rs +++ b/shared/src/model/input_update_action.rs @@ -24,7 +24,6 @@ pub enum InputUpdateCapabilities { Manual { policies: &'static [InputRefreshPolicy] }, Rescan, BulkOnly, - ImportUnavailable, Disabled, } @@ -38,9 +37,10 @@ impl InputUpdateCapabilities { InputType::Xtream | InputType::Stalker | InputType::M3u => Self::Manual { policies: &[InputRefreshPolicy::NORMAL, InputRefreshPolicy::REFRESH, InputRefreshPolicy::FORCE], }, - InputType::Plex => Self::Manual { policies: &[InputRefreshPolicy::NORMAL, InputRefreshPolicy::REFRESH] }, + InputType::Plex | InputType::Jellyfin | InputType::Emby => { + Self::Manual { policies: &[InputRefreshPolicy::NORMAL, InputRefreshPolicy::REFRESH] } + } InputType::Library => Self::Rescan, - InputType::Jellyfin | InputType::Emby => Self::ImportUnavailable, InputType::M3uBatch | InputType::XtreamBatch | InputType::StalkerBatch | InputType::Staged => { Self::BulkOnly } @@ -51,7 +51,7 @@ impl InputUpdateCapabilities { pub const fn policies(self) -> &'static [InputRefreshPolicy] { match self { Self::Manual { policies } => policies, - Self::Rescan | Self::BulkOnly | Self::ImportUnavailable | Self::Disabled => &[], + Self::Rescan | Self::BulkOnly | Self::Disabled => &[], } } @@ -82,8 +82,8 @@ mod tests { (InputType::M3u, 3), (InputType::Plex, 2), (InputType::Library, 0), - (InputType::Jellyfin, 0), - (InputType::Emby, 0), + (InputType::Jellyfin, 2), + (InputType::Emby, 2), (InputType::M3uBatch, 0), (InputType::XtreamBatch, 0), (InputType::StalkerBatch, 0),