feat: add Plex media-server catalog and playback baseline (#748)

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