Sorce ordinal by category and channel index (#672)

* Sorce ordinal by category and channel index
* Parsing UserApiRequest from request body or query
* CSS stylings
* media tools refactoring
* xtream info parsing now number/string fields
This commit is contained in:
euzu
2026-03-27 15:23:39 +01:00
committed by GitHub
parent 4df61af577
commit 9d35a9cbbe
46 changed files with 1644 additions and 421 deletions
Generated
+87 -30
View File
@@ -73,9 +73,9 @@ dependencies = [
[[package]]
name = "anstream"
version = "0.6.21"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
@@ -94,9 +94,9 @@ checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
@@ -129,9 +129,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arc-swap"
version = "1.8.2"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5"
checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6"
dependencies = [
"rustversion",
]
@@ -526,9 +526,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.60"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
@@ -536,9 +536,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.60"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
@@ -548,9 +548,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.55"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
@@ -677,13 +677,14 @@ dependencies = [
[[package]]
name = "cron"
version = "0.15.0"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740"
checksum = "089df96cf6a25253b4b6b6744d86f91150a3d4df546f31a95def47976b8cba97"
dependencies = [
"chrono",
"once_cell",
"winnow 0.6.26",
"phf 0.11.3",
"winnow 0.7.15",
]
[[package]]
@@ -1110,9 +1111,9 @@ dependencies = [
[[package]]
name = "env_logger"
version = "0.11.9"
version = "0.11.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
dependencies = [
"anstream",
"anstyle",
@@ -2732,9 +2733,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "openssl"
version = "0.10.75"
version = "0.10.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
@@ -2773,9 +2774,9 @@ dependencies = [
[[package]]
name = "openssl-sys"
version = "0.9.111"
version = "0.9.112"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
dependencies = [
"cc",
"libc",
@@ -2911,6 +2912,16 @@ dependencies = [
"sha2",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.12.1"
@@ -2926,7 +2937,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros",
"phf_macros 0.13.1",
"phf_shared 0.13.1",
"serde",
]
@@ -2937,10 +2948,20 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator",
"phf_generator 0.13.1",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.5",
]
[[package]]
name = "phf_generator"
version = "0.13.1"
@@ -2951,19 +2972,41 @@ dependencies = [
"phf_shared 0.13.1",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "phf_macros"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator",
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
@@ -3723,9 +3766,9 @@ dependencies = [
[[package]]
name = "saphyr-parser-bw"
version = "0.0.608"
version = "0.0.610"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55ae5ea09894b6d5382621db78f586df37ef18ab581bf32c754e75076b124b1"
checksum = "6d643f5e972f17219245b82f038c22cd3c74320bb17c6e8f7e8537de268b1bc6"
dependencies = [
"arraydeque",
"smallvec",
@@ -3821,9 +3864,9 @@ dependencies = [
[[package]]
name = "serde-saphyr"
version = "0.0.21"
version = "0.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6fc4aa0da972ba0f51cf5c1bb16e9dba35334adc6831b09b3ffb0ec20bb264"
checksum = "546b4da4f679832602a8f8ab8ddc10b6b1d2e1a13b4f9dddcaee499436fa06ad"
dependencies = [
"ahash",
"annotate-snippets",
@@ -3870,6 +3913,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serde_html_form"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0946d52b4b7e28823148aebbeceb901012c595ad737920d504fa8634bb099e6f"
dependencies = [
"form_urlencoded",
"indexmap",
"itoa",
"ryu",
"serde_core",
]
[[package]]
name = "serde_json"
version = "1.0.149"
@@ -4607,6 +4663,7 @@ dependencies = [
"rustls",
"serde",
"serde-saphyr",
"serde_html_form",
"serde_json",
"shared",
"socket2",
@@ -5400,9 +5457,9 @@ dependencies = [
[[package]]
name = "winnow"
version = "0.6.26"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
+3 -3
View File
@@ -20,7 +20,7 @@ fastrand = "2.3.0"
lz4_flex = "0.13.0"
indexmap = "2.13.0"
dashmap = "6.1.0"
arc-swap = "1.8.2"
arc-swap = "1.9.0"
paste = "1.0.15"
pest = "2.8.6"
pest_derive = "2.8.6"
@@ -28,10 +28,10 @@ zeroize = "1.8.2"
deunicode = "1.6.2"
path-clean = "1.0.1"
url = "2.5.8"
cron = "0.15.0"
cron = "0.16.0"
enum-iterator = "2.3.0"
futures = "0.3.32"
serde-saphyr = "0.0.21"
serde-saphyr = "0.0.22"
tokio = { version = "1.50.0" }
[profile.release]
+6 -5
View File
@@ -15,13 +15,14 @@ serde-saphyr.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
quick-xml = { version = "0.39.2", features = ["async-tokio", "serialize"] }
regex.workspace = true
clap = { version = "4.5.60", features = ["derive"] }
clap = { version = "4.6.0", features = ["derive"] }
reqwest = { version = "0.13.2", features = ["json", "stream", "rustls", "socks", "form"] }
url.workspace = true
chrono.workspace = true
iana-time-zone = "0.1.65"
cron.workspace = true
axum = { version = "0.8.8" , features = ["macros", "default", "ws"]}
serde_html_form = "0.4.0"
tower = "0.5.3"
tower-http = { version = "0.6.8", features = ["cors", "auth", "fs", "compression-full", "trace"] }
tower_governor = { version = "0.8.0", features = ["axum"] }
@@ -32,11 +33,11 @@ path-clean.workspace = true
pest.workspace = true
pest_derive.workspace = true
enum-iterator.workspace = true
openssl = { version = "0.10.75", features = ["vendored"] } #https://docs.rs/openssl/0.10.34/openssl/#vendored
openssl = { version = "0.10.76", features = ["vendored"] } #https://docs.rs/openssl/0.10.34/openssl/#vendored
deunicode.workspace = true
mime = "0.3.17"
log.workspace = true
env_logger = "0.11.9"
env_logger = "0.11.10"
rmp-serde = "1.3.1"
rand = "0.9.2"
fastrand.workspace = true
@@ -75,7 +76,8 @@ uuid = { version = "1.22.0", features = ["v4"] }
fancy-regex = "0.17.0"
mime_guess = "2.0.5"
crc32fast = "1.5.0"
memchr = "2.7.6"
memchr = "2.8.0"
http-body-util = "0.1.3"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -94,7 +96,6 @@ windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Syste
vergen = { version = "9.1.0", features = ["build"] }
[dev-dependencies]
http-body-util = "0.1.3"
tokio = { workspace = true, features = ["test-util", "macros"] }
rcgen = "0.14.7"
rustls = "0.23.37"
+1 -1
View File
@@ -11,7 +11,6 @@ use crate::{
},
auth::Fingerprint,
model::{ConfigInput, ConfigTarget, ProxyUserCredentials},
tools::lru_cache::LRUResourceCache,
utils::{
async_file_reader, async_file_writer, create_new_file_for_write, debug_if_enabled, get_file_extension, request,
request::{content_type_from_ext, parse_range, send_with_retry_and_provider},
@@ -206,6 +205,7 @@ pub use try_result_bad_request;
pub use try_result_not_found;
pub use try_result_or_status;
pub use try_unwrap_body;
use crate::utils::LRUResourceCache;
pub fn get_server_time() -> String {
chrono::offset::Local::now().with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M:%S %Z").to_string()
+2 -5
View File
@@ -12,7 +12,7 @@ use crate::{
hls_api::handle_hls_stream_request,
xtream_api::{ApiStreamContext, ApiStreamRequest},
},
model::{create_custom_video_stream_response, AppState, CustomVideoStreamType, UserApiRequest},
model::{create_custom_video_stream_response, AppState, CustomVideoStreamType, UserApiRequestQueryOrBody, UserApiRequest},
},
auth::Fingerprint,
repository::{m3u_get_item_for_stream_id, m3u_load_rewrite_playlist, storage_const},
@@ -69,12 +69,9 @@ async fn m3u_api_get(
}
async fn m3u_api_post(
axum::extract::Query(api_query_req): axum::extract::Query<UserApiRequest>,
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
api_form_req: Result<axum::extract::Form<UserApiRequest>, axum::extract::rejection::FormRejection>,
UserApiRequestQueryOrBody(api_req): UserApiRequestQueryOrBody,
) -> impl IntoResponse + Send {
let form_req = api_form_req.as_ref().ok().map(|form| &form.0);
let api_req = UserApiRequest::merge_query_over_form(&api_query_req, form_req);
m3u_api(&api_req, &app_state).await.into_response()
}
+2 -8
View File
@@ -4,7 +4,7 @@ use crate::{
empty_json_response_as_array, get_user_target, get_user_target_by_credentials, internal_server_error,
resource_response, stream_json_or_bin_response_stream, try_option_forbidden, try_unwrap_body,
},
model::{AppState, UserApiRequest},
model::{AppState, UserApiRequestQueryOrBody, UserApiRequest},
},
model::{Config, ConfigTarget, ProxyUserCredentials, TargetOutput, EPG_ATTRIB_ID, EPG_TAG_CHANNEL},
repository::{
@@ -492,14 +492,8 @@ async fn xmltv_api_get(
async fn xmltv_api_post(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Query(api_query_req): axum::extract::Query<UserApiRequest>,
api_form_req: Result<axum::extract::Form<UserApiRequest>, axum::extract::rejection::FormRejection>,
UserApiRequestQueryOrBody(api_req): UserApiRequestQueryOrBody,
) -> impl IntoResponse + Send {
if let Err(ref rejection) = api_form_req {
debug!("xmltv_api_post: form parsing failed: {rejection:?}");
}
let form_req = api_form_req.as_ref().ok().map(|form| &form.0);
let api_req = UserApiRequest::merge_query_over_form(&api_query_req, form_req);
xmltv_api(api_req, &app_state).await
}
+4 -13
View File
@@ -17,7 +17,7 @@ use crate::{
xmltv_api::{get_empty_epg_response, get_epg_path_for_target, serve_short_epg},
},
model::{
create_custom_video_stream_response, AppState, CustomVideoStreamType, UserApiRequest,
create_custom_video_stream_response, AppState, CustomVideoStreamType, UserApiRequestQueryOrBody, UserApiRequest,
XtreamAuthorizationResponse,
},
},
@@ -698,13 +698,10 @@ struct XtreamApiTimeShiftRequest {
async fn xtream_player_api_timeshift_stream(
fingerprint: Fingerprint,
req_headers: HeaderMap,
axum::extract::Query(api_query_req): axum::extract::Query<UserApiRequest>,
axum::extract::Path(timeshift_request): axum::extract::Path<XtreamApiTimeShiftRequest>,
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
api_form_req: Result<axum::extract::Form<UserApiRequest>, axum::extract::rejection::FormRejection>,
UserApiRequestQueryOrBody(query_req): UserApiRequestQueryOrBody,
) -> impl IntoResponse + Send {
let form_req = api_form_req.as_ref().ok().map(|form| &form.0);
let query_req = UserApiRequest::merge_query_over_form(&api_query_req, form_req);
let path_req = UserApiRequest {
username: timeshift_request.username,
password: timeshift_request.password,
@@ -752,12 +749,9 @@ async fn xtream_player_api_timeshift_stream(
async fn xtream_player_api_timeshift_query_stream(
fingerprint: Fingerprint,
req_headers: HeaderMap,
axum::extract::Query(api_query_req): axum::extract::Query<UserApiRequest>,
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
api_form_req: Result<axum::extract::Form<UserApiRequest>, axum::extract::rejection::FormRejection>,
UserApiRequestQueryOrBody(api_req): UserApiRequestQueryOrBody,
) -> impl IntoResponse + Send {
let form_req = api_form_req.as_ref().ok().map(|form| &form.0);
let api_req = UserApiRequest::merge_query_over_form(&api_query_req, form_req);
if api_req.username.is_empty()
|| api_req.password.is_empty()
@@ -1314,11 +1308,8 @@ async fn xtream_player_api_get(
async fn xtream_player_api_post(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Query(api_query_req): axum::extract::Query<UserApiRequest>,
api_form_req: Result<axum::extract::Form<UserApiRequest>, axum::extract::rejection::FormRejection>,
UserApiRequestQueryOrBody(api_req): UserApiRequestQueryOrBody,
) -> impl IntoResponse + Send {
let form_req = api_form_req.as_ref().ok().map(|form| &form.0);
let api_req = UserApiRequest::merge_query_over_form(&api_query_req, form_req);
xtream_player_api(api_req, &app_state).await
}
@@ -1104,7 +1104,7 @@ mod tests {
use super::ActiveProviderManager;
use crate::{
api::model::{EventManager, ProviderAllocation},
model::{AppConfig, Config, ConfigInput, ConfigInputAlias, SourcesConfig},
model::{AppConfig, Config, ConfigInput, ConfigInputAlias, MediaToolCapabilities, SourcesConfig},
utils::FileLockManager,
};
use arc_swap::{ArcSwap, ArcSwapOption};
@@ -1156,7 +1156,7 @@ mod tests {
custom_stream_response: Arc::new(ArcSwapOption::default()),
access_token_secret: [0; 32],
encrypt_secret: [0; 16],
ffprobe_available: Arc::default(),
media_tools: Arc::new(MediaToolCapabilities::new()),
}
}
+1 -1
View File
@@ -14,7 +14,6 @@ use crate::{
ProcessTargets, ReverseProxyDisabledHeaderConfig, ScheduleConfig, SourcesConfig,
},
repository::{get_geoip_path, load_target_into_memory_cache},
tools::lru_cache::LRUResourceCache,
utils::{
request::{create_client, create_client_with_redirect},
GeoIp,
@@ -37,6 +36,7 @@ use tokio::sync::{mpsc, Mutex};
use tokio::task;
use tokio_util::sync::CancellationToken;
use url::Url;
use crate::utils::LRUResourceCache;
macro_rules! cancel_service {
($field: ident, $flag:expr, $changes:expr, $cancel_tokens:expr) => {
+292 -1
View File
@@ -1,4 +1,11 @@
use axum::extract::{FromRequest, Request};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use http_body_util::LengthLimitError;
use log::log_enabled;
use std::error::Error as StdError;
const MAX_BODY_SIZE_BYTES: usize = 10 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
pub struct UserApiRequest {
@@ -32,7 +39,194 @@ pub struct UserApiRequest {
pub content_type: String,
}
/// Custom extractor that parses `UserApiRequest` from query parameters and request body
/// (either `application/x-www-form-urlencoded` or `multipart/form-data`), then merges
/// them with query parameters taking priority over body fields.
pub struct UserApiRequestQueryOrBody(pub UserApiRequest);
#[derive(Debug)]
enum ParseBodyError {
PayloadTooLarge(String),
BadRequest(String),
}
impl ParseBodyError {
fn into_response(self) -> Response {
match self {
Self::PayloadTooLarge(err) => (StatusCode::PAYLOAD_TOO_LARGE, err).into_response(),
Self::BadRequest(err) => (StatusCode::BAD_REQUEST, err).into_response(),
}
}
}
impl<S> FromRequest<S> for UserApiRequestQueryOrBody
where
S: Send + Sync,
{
type Rejection = Response;
async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> {
let (parts, body) = req.into_parts();
let query_req = parse_query_request(parts.uri.query())
.map_err(ParseBodyError::into_response)?;
let content_type = parts
.headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let body_req = match parse_body(body, content_type).await {
Ok(body_req) => Some(body_req),
Err(err) => return Err(err.into_response()),
};
Ok(UserApiRequestQueryOrBody(UserApiRequest::merge_query_over_form(&query_req, body_req.as_ref())))
}
}
async fn parse_body(body: axum::body::Body, content_type: &str) -> Result<UserApiRequest, ParseBodyError> {
let bytes = axum::body::to_bytes(body, MAX_BODY_SIZE_BYTES)
.await
.map_err(|e| {
if is_length_limit_error(&e) {
ParseBodyError::PayloadTooLarge(format!("Request body too large (max {MAX_BODY_SIZE_BYTES} bytes)"))
} else {
ParseBodyError::BadRequest(format!("Failed to read request body: {e}"))
}
})?;
if bytes.is_empty() {
return Ok(UserApiRequest::default());
}
if is_multipart_content_type(content_type) {
parse_multipart_body(&bytes, content_type)
} else {
// Treat as form-urlencoded (works for both explicit content-type and missing content-type)
serde_html_form::from_bytes(&bytes).map_err(|e| ParseBodyError::BadRequest(format!("Failed to parse form: {e}")))
}
}
fn parse_multipart_body(bytes: &[u8], content_type: &str) -> Result<UserApiRequest, ParseBodyError> {
let boundary = extract_multipart_boundary(content_type)
.ok_or_else(|| ParseBodyError::BadRequest("Missing boundary in multipart content type".to_string()))?;
let data = std::str::from_utf8(bytes).map_err(|e| ParseBodyError::BadRequest(format!("Invalid UTF-8: {e}")))?;
let mut request = UserApiRequest::default();
let delimiter = format!("--{boundary}");
for part in data.split(&delimiter) {
if let Some((name, value)) = parse_multipart_field(part) {
request.set_field(name, value);
}
}
Ok(request)
}
fn parse_query_request(query: Option<&str>) -> Result<UserApiRequest, ParseBodyError> {
match query {
Some(query) => serde_html_form::from_str(query)
.map_err(|e| ParseBodyError::BadRequest(format!("Failed to parse query: {e}"))),
None => Ok(UserApiRequest::default()),
}
}
fn parse_multipart_field(part: &str) -> Option<(&str, &str)> {
let header_end = part.find("\r\n\r\n")?;
let headers = &part[..header_end];
let body = &part[header_end + 4..];
let name = extract_multipart_field_name(headers)?;
let value = body.strip_suffix("\r\n").unwrap_or(body);
Some((name, value))
}
fn extract_multipart_field_name(headers: &str) -> Option<&str> {
let disposition = headers
.split("\r\n")
.find(|line| line.trim_start().to_ascii_lowercase().starts_with("content-disposition:"))?;
let (_, attrs) = disposition.split_once(':')?;
for attr in attrs.split(';').skip(1) {
let (name, value) = attr.trim().split_once('=')?;
if !name.trim().eq_ignore_ascii_case("name") {
continue;
}
let value = value.trim();
let unquoted_double = value.strip_prefix('"').and_then(|inner| inner.strip_suffix('"'));
if let Some(result) = unquoted_double {
return Some(result);
}
let unquoted_single = value.strip_prefix('\'').and_then(|inner| inner.strip_suffix('\''));
if let Some(result) = unquoted_single {
return Some(result);
}
return Some(value);
}
None
}
fn is_length_limit_error(err: &axum::Error) -> bool {
let mut current: Option<&(dyn StdError + 'static)> = Some(err);
while let Some(source) = current {
if source.is::<LengthLimitError>() {
return true;
}
current = source.source();
}
false
}
fn is_multipart_content_type(content_type: &str) -> bool {
content_type
.split(';')
.next()
.is_some_and(|media_type| media_type.trim().eq_ignore_ascii_case("multipart/form-data"))
}
fn extract_multipart_boundary(content_type: &str) -> Option<&str> {
for param in content_type.split(';').skip(1) {
let (name, value) = param.split_once('=')?;
if name.trim().eq_ignore_ascii_case("boundary") {
let trimmed = value.trim();
let unquoted = trimmed
.strip_prefix('"')
.and_then(|inner| inner.strip_suffix('"'))
.unwrap_or(trimmed);
if !unquoted.is_empty() {
return Some(unquoted);
}
}
}
None
}
impl UserApiRequest {
fn set_field(&mut self, name: &str, value: &str) {
match name {
"username" => self.username = value.to_string(),
"password" => self.password = value.to_string(),
"token" => self.token = value.to_string(),
"action" => self.action = value.to_string(),
"series_id" => self.series_id = value.to_string(),
"vod_id" => self.vod_id = value.to_string(),
"stream_id" => self.stream_id = value.to_string(),
"category_id" => self.category_id = value.to_string(),
"limit" => self.limit = value.to_string(),
"start" => self.start = value.to_string(),
"end" => self.end = value.to_string(),
"stream" => self.stream = value.to_string(),
"duration" => self.duration = value.to_string(),
"type" | "content_type" => self.content_type = value.to_string(),
_ => {}
}
}
pub fn merge_prefer_primary(primary: &Self, fallback: &Self) -> Self {
fn pick(primary: &str, fallback: &str) -> String {
if primary.trim().is_empty() {
@@ -97,7 +291,10 @@ impl UserApiRequest {
#[cfg(test)]
mod tests {
use super::UserApiRequest;
use super::{parse_body, UserApiRequest, MAX_BODY_SIZE_BYTES};
use axum::body::Body;
use axum::extract::FromRequest;
use axum::http::{Request as HttpRequest, StatusCode};
#[test]
fn merge_prefer_primary_uses_fallback_for_empty_fields() {
@@ -194,4 +391,98 @@ mod tests {
assert_eq!(merged.token, "query-token");
assert_eq!(merged.action, "query-action");
}
#[tokio::test]
async fn parse_body_rejects_oversized_payloads() {
let oversized = "a".repeat(MAX_BODY_SIZE_BYTES + 1);
let err = parse_body(Body::from(oversized), "application/x-www-form-urlencoded")
.await
.expect_err("oversized bodies should be rejected");
match err {
super::ParseBodyError::PayloadTooLarge(msg) => {
assert!(msg.contains("body too large"), "unexpected error: {msg}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn extractor_surfaces_oversized_body_as_bad_request() {
let oversized = "a".repeat(MAX_BODY_SIZE_BYTES + 1);
let request = HttpRequest::builder()
.header("content-type", "application/x-www-form-urlencoded")
.uri("/player_api.php")
.body(Body::from(oversized))
.expect("request should build");
let response = match super::UserApiRequestQueryOrBody::from_request(request, &()).await {
Ok(_) => panic!("oversized body should reject extractor"),
Err(response) => response,
};
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn parse_body_detects_multipart_case_insensitively_and_preserves_field_whitespace() {
let body = concat!(
"--abc123\r\n",
"Content-Disposition: form-data; name=\"username\"\r\n\r\n",
" alice \r\n",
"--abc123\r\n",
"Content-Disposition: form-data; name=\"password\"\r\n\r\n",
"\t secret \t\r\n",
"--abc123--\r\n",
);
let parsed = parse_body(
Body::from(body),
"Multipart/Form-Data; charset=utf-8; boundary=\"abc123\"",
)
.await
.expect("multipart body should parse");
assert_eq!(parsed.username, " alice ");
assert_eq!(parsed.password, "\t secret \t");
}
#[tokio::test]
async fn parse_body_accepts_content_type_field_name_in_multipart() {
let body = concat!(
"--abc123\r\n",
"Content-Disposition: form-data; name=\"content_type\"\r\n\r\n",
"m3u_plus\r\n",
"--abc123--\r\n",
);
let parsed = parse_body(
Body::from(body),
"multipart/form-data; boundary=abc123",
)
.await
.expect("multipart body should parse");
assert_eq!(parsed.content_type, "m3u_plus");
}
#[tokio::test]
async fn parse_body_uses_content_disposition_name_not_filename() {
let body = concat!(
"--abc123\r\n",
"Content-Disposition: form-data; filename=\"name=\\\"wrong\\\".txt\"; name=\"username\"\r\n\r\n",
"alice\r\n",
"--abc123--\r\n",
);
let parsed = parse_body(
Body::from(body),
"multipart/form-data; boundary=abc123",
)
.await
.expect("multipart body should parse");
assert_eq!(parsed.username, "alice");
}
}
@@ -1044,7 +1044,7 @@ mod tests {
StreamError, UpdateGuard,
},
auth::Fingerprint,
model::{AppConfig, Config, ConfigInput, GracePeriodOptions, ProcessTargets, ProxyUserCredentials, SourcesConfig},
model::{AppConfig, Config, ConfigInput, GracePeriodOptions, MediaToolCapabilities, ProcessTargets, ProxyUserCredentials, SourcesConfig},
utils::{FileLockManager, GeoIp},
};
use arc_swap::{ArcSwap, ArcSwapOption};
@@ -1106,7 +1106,7 @@ mod tests {
custom_stream_response: Arc::new(ArcSwapOption::default()),
access_token_secret: [0; 32],
encrypt_secret: [0; 16],
ffprobe_available: Arc::default(),
media_tools: Arc::new(MediaToolCapabilities::new()),
}
}
@@ -287,7 +287,7 @@ mod tests {
use super::{create_channel_unavailable_stream, CustomVideoStreamType};
use crate::{
api::model::TransportStreamBuffer,
model::{AppConfig, Config, ConfigInput, CustomStreamResponse, SourcesConfig},
model::{AppConfig, Config, ConfigInput, CustomStreamResponse, MediaToolCapabilities, SourcesConfig},
utils::FileLockManager,
};
use arc_swap::{ArcSwap, ArcSwapOption};
@@ -338,7 +338,7 @@ mod tests {
custom_stream_response: Arc::new(ArcSwapOption::default()),
access_token_secret: [0; 32],
encrypt_secret: [0; 16],
ffprobe_available: Arc::default(),
media_tools: Arc::new(MediaToolCapabilities::new()),
};
let mut ts_packet = vec![0_u8; 188];
@@ -749,7 +749,7 @@ mod tests {
use super::{SharedStreamManager, SharedStreamState, CHANNEL_SIZE};
use crate::{
api::model::{ActiveProviderManager, EventManager},
model::{AppConfig, Config, ConfigInput, SourcesConfig},
model::{AppConfig, Config, ConfigInput, MediaToolCapabilities, SourcesConfig},
utils::FileLockManager,
};
use arc_swap::{ArcSwap, ArcSwapOption};
@@ -803,7 +803,7 @@ mod tests {
custom_stream_response: Arc::new(ArcSwapOption::default()),
access_token_secret: [0; 32],
encrypt_secret: [0; 16],
ffprobe_available: Arc::default(),
media_tools: Arc::new(MediaToolCapabilities::new()),
}
}
+64 -9
View File
@@ -5,6 +5,7 @@ use crate::library::metadata_storage::MetadataStorage;
use crate::library::scanner::LibraryScanner;
use crate::library::{MediaGroup, MediaGrouper, thumbnail::{self, ThumbnailExtractor}};
use crate::model::{AppConfig, LibraryConfig, MetadataUpdateConfig};
use crate::utils::ffmpeg::FfmpegExecutor;
use log::{debug, error, info, warn};
use path_clean::PathClean;
use shared::model::{LibraryMetadataFormat, LibraryScanResult};
@@ -135,9 +136,23 @@ impl LibraryProcessor {
false
};
let ffmpeg_available = if self.thumbnail_extractor.is_some() {
if let Some(app_cfg) = &self.app_config {
app_cfg.is_ffmpeg_available().await
} else {
FfmpegExecutor::new().check_ffmpeg_availability().await
}
} else {
false
};
if self.thumbnail_extractor.is_some() && !ffmpeg_available {
warn!("Thumbnail extraction disabled because ffmpeg is unavailable");
}
// Process each scanned file
for group in &media_groups {
match self.process_group(group, &existing_map, force_rescan, ffprobe_enabled).await {
match self.process_group(group, &existing_map, force_rescan, ffprobe_enabled, ffmpeg_available).await {
Ok(action) => match action {
ProcessAction::Added => result.files_added += 1,
ProcessAction::Updated => result.files_updated += 1,
@@ -170,7 +185,7 @@ impl LibraryProcessor {
}
}
if self.thumbnail_extractor.is_some() {
if ffmpeg_available {
self.storage.cleanup_orphaned_thumbnails().await;
}
@@ -178,13 +193,20 @@ impl LibraryProcessor {
Ok(result)
}
async fn process_group(&self, group: &MediaGroup, existing_map: &HashMap<String, MetadataCacheEntry>, force_rescan: bool, can_probe: bool) -> Result<ProcessAction, String> {
async fn process_group(
&self,
group: &MediaGroup,
existing_map: &HashMap<String, MetadataCacheEntry>,
force_rescan: bool,
can_probe: bool,
can_extract_thumbnails: bool,
) -> Result<ProcessAction, String> {
match group {
MediaGroup::Movie { file: _, .. } => {
self.process_movie(group, existing_map, force_rescan, can_probe).await
self.process_movie(group, existing_map, force_rescan, can_probe, can_extract_thumbnails).await
}
MediaGroup::Series { show_key: _, episodes: _ } => {
self.process_series_group(group, existing_map, force_rescan, can_probe).await
self.process_series_group(group, existing_map, force_rescan, can_probe, can_extract_thumbnails).await
}
}
}
@@ -210,7 +232,14 @@ impl LibraryProcessor {
//}
// Processes a single video file
async fn process_movie(&self, group: &MediaGroup, existing_map: &HashMap<String, MetadataCacheEntry>, force_rescan: bool, _can_probe: bool) -> Result<ProcessAction, String> {
async fn process_movie(
&self,
group: &MediaGroup,
existing_map: &HashMap<String, MetadataCacheEntry>,
force_rescan: bool,
_can_probe: bool,
can_extract_thumbnails: bool,
) -> Result<ProcessAction, String> {
let MediaGroup::Movie { file, .. } = group else { return Err(format!("Expected movie to resolve but got {group}")) };
// Check if file already exists in cache
let (mut cache_entry, status) = if let Some(existing_entry) = existing_map.get(&file.file_path) {
@@ -251,7 +280,12 @@ impl LibraryProcessor {
(entry, ProcessAction::Added)
};
self.extract_thumbnail_if_needed(&mut cache_entry, &file.file_path, file.modified_timestamp).await;
self.extract_thumbnail_if_needed(
&mut cache_entry,
&file.file_path,
file.modified_timestamp,
can_extract_thumbnails,
).await;
self.storage.store(&cache_entry).await.map_err(|e| e.to_string())?;
self.write_metadata_files(&cache_entry).await.map_err(|e| e.to_string())?;
Ok(status)
@@ -263,7 +297,9 @@ impl LibraryProcessor {
&self,
group: &MediaGroup,
existing_map: &HashMap<String, MetadataCacheEntry>,
force_rescan: bool, _can_probe: bool
force_rescan: bool,
_can_probe: bool,
can_extract_thumbnails: bool,
) -> Result<ProcessAction, String> {
let MediaGroup::Series { show_key, episodes } = group else { return Err(format!("Expected series to resolve but got {group}")) };
let series_file_path = episodes
@@ -370,6 +406,7 @@ impl LibraryProcessor {
&episode.file.file_path,
episode.file.modified_timestamp,
prev_mtime,
can_extract_thumbnails,
).await;
} else {
let mut new_episode = series_episode.clone();
@@ -381,6 +418,7 @@ impl LibraryProcessor {
&episode.file.file_path,
episode.file.modified_timestamp,
Some(previous_file_modified),
can_extract_thumbnails,
).await;
double_episodes.push(new_episode);
}
@@ -404,6 +442,7 @@ impl LibraryProcessor {
&mut chache_entry,
&first_ep.file.file_path,
first_ep.file.modified_timestamp,
can_extract_thumbnails,
).await;
}
}
@@ -426,7 +465,12 @@ impl LibraryProcessor {
cache_entry: &mut MetadataCacheEntry,
file_path: &str,
file_mtime: i64,
can_extract_thumbnails: bool,
) {
if !can_extract_thumbnails {
return;
}
// Skip if already has a poster from TMDB/NFO
if cache_entry.metadata.poster().is_some() {
// Clear stale generated-thumbnail references so they can be reclaimed
@@ -471,6 +515,7 @@ impl LibraryProcessor {
file_path: &str,
file_mtime: i64,
previous_file_mtime: Option<i64>,
can_extract_thumbnails: bool,
) {
if !episode.thumb.as_deref().unwrap_or_default().is_empty()
&& episode.thumbnail_id.as_deref().unwrap_or_default().is_empty()
@@ -478,7 +523,12 @@ impl LibraryProcessor {
return;
}
if let Some(thumbnail_id) = self.extract_thumbnail_id_for_file(file_path, file_mtime, previous_file_mtime).await {
if let Some(thumbnail_id) = self.extract_thumbnail_id_for_file(
file_path,
file_mtime,
previous_file_mtime,
can_extract_thumbnails,
).await {
episode.thumbnail_id = Some(thumbnail_id);
}
}
@@ -488,7 +538,12 @@ impl LibraryProcessor {
file_path: &str,
file_mtime: i64,
previous_file_mtime: Option<i64>,
can_extract_thumbnails: bool,
) -> Option<String> {
if !can_extract_thumbnails {
return None;
}
let extractor = self.thumbnail_extractor.as_ref()?;
let hash = thumbnail::file_hash(file_path);
+10 -87
View File
@@ -1,10 +1,6 @@
use crate::model::ThumbnailConfig;
use log::debug;
use crate::utils::ffmpeg::FfmpegExecutor;
use std::path::Path;
use std::time::Duration;
use tokio::process::Command;
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(60);
/// Computes a stable BLAKE3 hash for a file path (or URL).
pub fn file_hash(path: &str) -> String {
@@ -13,16 +9,14 @@ pub fn file_hash(path: &str) -> String {
pub struct ThumbnailExtractor {
config: ThumbnailConfig,
ffmpeg: FfmpegExecutor,
}
impl ThumbnailExtractor {
pub fn new(config: ThumbnailConfig) -> Self { Self { config } }
/// Checks if the system `ffmpeg` binary is available.
pub async fn check_ffmpeg_availability() -> bool {
match Command::new("ffmpeg").arg("-version").output().await {
Ok(output) => output.status.success(),
Err(_) => false,
pub fn new(config: ThumbnailConfig) -> Self {
Self {
config,
ffmpeg: FfmpegExecutor::new(),
}
}
@@ -37,85 +31,14 @@ impl ThumbnailExtractor {
return Err(format!("File not found: {file_path}"));
}
self.run_ffmpeg(file_path).await
self.ffmpeg
.create_thumbnail(file_path, self.config.width, self.config.height)
.await
}
// TODO: Future feature: support thumbnail extraction from remote HTTP(S)
// inputs via ranged reads without tying this logic into the local library
// scan path yet.
fn build_scale_filter(&self) -> String {
format!(
"scale={}:{}:force_original_aspect_ratio=increase,crop={}:{}",
self.config.width, self.config.height, self.config.width, self.config.height
)
}
/// Runs ffmpeg to extract a single frame at ~180s into the video.
/// Falls back to position 0 if the video is shorter than 180s.
async fn run_ffmpeg(&self, input_path: &str) -> Result<Vec<u8>, String> {
let temp_dir = tempfile::tempdir()
.map_err(|e| format!("Failed to create temp dir: {e}"))?;
let output_path = temp_dir.path().join("thumb.jpg");
let scale_filter = self.build_scale_filter();
let output = self.run_ffmpeg_with_timeout(&[
"-ss", "180",
"-i", input_path,
"-frames:v", "1",
"-vf", &scale_filter,
"-q:v", "1",
"-y",
&output_path.to_string_lossy(),
]).await
.map_err(|e| format!("Failed to run ffmpeg: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// Retry at position 0 if seeking past end of short video
if stderr.contains("Output file is empty") || stderr.contains("nothing was encoded") {
debug!("Video shorter than 180s, retrying at position 0: {input_path}");
let output = self.run_ffmpeg_with_timeout(&[
"-ss", "0",
"-i", input_path,
"-frames:v", "1",
"-vf", &scale_filter,
"-q:v", "1",
"-y",
&output_path.to_string_lossy(),
]).await
.map_err(|e| format!("Failed to run ffmpeg retry: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("ffmpeg failed at position 0: {stderr}"));
}
} else {
return Err(format!("ffmpeg failed: {stderr}"));
}
}
tokio::fs::read(&output_path)
.await
.map_err(|e| format!("Failed to read thumbnail: {e}"))
}
/// Spawns ffmpeg with the given args and enforces a bounded timeout.
/// On timeout the child process is killed via `kill_on_drop`.
async fn run_ffmpeg_with_timeout(&self, args: &[&str]) -> Result<std::process::Output, String> {
let child = Command::new("ffmpeg")
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| format!("Failed to spawn ffmpeg: {e}"))?;
tokio::time::timeout(FFMPEG_TIMEOUT, child.wait_with_output())
.await
.map_err(|_| "Timed out running ffmpeg".to_string())?
.map_err(|e| e.to_string())
}
}
#[cfg(test)]
@@ -141,4 +64,4 @@ mod tests {
let url = format!("/api/v1/library/thumbnail/{}", "test-uuid-123");
assert_eq!(url, "/api/v1/library/thumbnail/test-uuid-123");
}
}
}
+2 -2
View File
@@ -296,7 +296,7 @@ async fn resolve_template<'a>(app_config: &'a Arc<AppConfig>, http_client: &'a r
#[cfg(test)]
mod tests {
use arc_swap::{ArcSwap, ArcSwapOption};
use crate::model::ProcessingStats;
use crate::model::{MediaToolCapabilities, ProcessingStats};
use super::*;
use shared::model::{ConfigPaths};
use crate::utils::FileLockManager;
@@ -324,7 +324,7 @@ mod tests {
custom_stream_response: Arc::new(ArcSwapOption::default()),
access_token_secret: [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],
encrypt_secret: [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16],
ffprobe_available: Arc::default(),
media_tools: Arc::new(MediaToolCapabilities::new()),
})
}
+7 -7
View File
@@ -1,11 +1,10 @@
use crate::api::model::TransportStreamBuffer;
use crate::model::{
ApiProxyConfig, ApiProxyServerInfo, Config, ConfigInput, ConfigInputOptions, ConfigTarget, CustomStreamResponse,
GracePeriodOptions, HdHomeRunConfig, HdHomeRunFlags, Mappings, ProxyUserCredentials,
GracePeriodOptions, HdHomeRunConfig, HdHomeRunFlags, Mappings, MediaToolCapabilities, ProxyUserCredentials,
ReverseProxyDisabledHeaderConfig, SourcesConfig, TargetOutput,
};
use crate::utils;
use crate::utils::ffmpeg::check_ffprobe_availability;
use arc_swap::{ArcSwap, ArcSwapOption};
use log::{error, warn};
use rand::Rng;
@@ -22,7 +21,6 @@ use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::OnceCell;
fn generate_secret() -> [u8; 32] {
let mut rng = rand::rng();
@@ -42,7 +40,7 @@ pub struct AppConfig {
pub custom_stream_response: Arc<ArcSwapOption<CustomStreamResponse>>,
pub access_token_secret: [u8; 32],
pub encrypt_secret: [u8; 16],
pub(crate) ffprobe_available: Arc<OnceCell<bool>>,
pub(crate) media_tools: Arc<MediaToolCapabilities>,
}
impl AppConfig {
@@ -481,8 +479,10 @@ impl AppConfig {
return false;
}
*self.ffprobe_available.get_or_init(|| async {
check_ffprobe_availability().await
}).await
self.media_tools.is_ffprobe_available().await
}
pub async fn is_ffmpeg_available(&self) -> bool {
self.media_tools.is_ffmpeg_available().await
}
}
+48
View File
@@ -0,0 +1,48 @@
use crate::utils::ffmpeg::FfmpegExecutor;
use shared::create_bitset;
use tokio::sync::OnceCell;
create_bitset!(u8, MediaToolCapability, Ffmpeg, Ffprobe);
#[derive(Debug, Default)]
pub struct MediaToolCapabilities {
available: OnceCell<MediaToolCapabilitySet>,
}
impl MediaToolCapabilities {
#[must_use]
pub fn new() -> Self {
Self {
available: OnceCell::new(),
}
}
pub async fn is_ffmpeg_available(&self) -> bool {
self.available().await.contains(MediaToolCapability::Ffmpeg)
}
pub async fn is_ffprobe_available(&self) -> bool {
self.available().await.contains(MediaToolCapability::Ffprobe)
}
async fn available(&self) -> MediaToolCapabilitySet {
*self.available.get_or_init(Self::detect_available_tools).await
}
async fn detect_available_tools() -> MediaToolCapabilitySet {
let executor = FfmpegExecutor::new();
let ffmpeg = executor.check_ffmpeg_availability();
let ffprobe = executor.check_ffprobe_availability();
let (ffmpeg_available, ffprobe_available) = tokio::join!(ffmpeg, ffprobe);
let mut available = MediaToolCapabilitySet::new();
if ffmpeg_available {
available.set(MediaToolCapability::Ffmpeg);
}
if ffprobe_available {
available.set(MediaToolCapability::Ffprobe);
}
available
}
}
+2
View File
@@ -4,6 +4,7 @@ mod web_ui;
mod web_auth;
mod messaging;
mod metadata_update;
mod media_tools;
mod hdhomerun;
mod ip_check;
mod source;
@@ -46,6 +47,7 @@ pub use input::*;
pub use ip_check::*;
pub use log::*;
pub use messaging::*;
pub use media_tools::*;
pub use metadata_update::*;
pub use proxy::*;
pub use rate_limit::*;
-1
View File
@@ -12,7 +12,6 @@ macro_rules! include_modules {
pub mod processing;
pub mod ptt;
pub mod repository;
pub mod tools;
pub mod utils;
};
}
+213 -23
View File
@@ -11,9 +11,29 @@ use shared::model::{EpisodeStreamProperties, LiveStreamProperties, PlaylistGroup
SeriesStreamProperties, StreamProperties, VideoStreamProperties,
XtreamCluster, XtreamPlaylistItem};
use shared::utils::{generate_playlist_uuid, trim_last_slash, Internable};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::task::spawn_blocking;
/// Bucket size for composite ordinal encoding in the streaming parser.
/// Layout: `cat_position * CAT_BUCKET + within_cat_counter`.
/// Supports up to ~42 900 categories with up to 100 000 streams each.
const CAT_BUCKET: u32 = 100_000;
fn next_group_map_key<T>(group_map: &IndexMap<u32, T>) -> u32 {
if let Some(max_key) = group_map.keys().copied().max() {
if let Some(next_key) = max_key.checked_add(1) {
return next_key;
}
}
let mut candidate = 0u32;
while group_map.contains_key(&candidate) {
candidate = candidate.saturating_add(1);
}
candidate
}
async fn map_to_xtream_category(categories: DynReader, input_name: &Arc<str>) -> Result<Vec<XtreamCategory>, TuliproxError> {
let input_name_clone = Arc::clone(input_name);
spawn_blocking(move || {
@@ -174,12 +194,12 @@ pub async fn parse_xtream(input: &ConfigInput,
input.has_flag(ConfigInputFlags::XtreamLiveStreamWithoutExtension),
);
for (ord_counter, stream) in (1_u32..).zip(xtream_streams) {
for stream in xtream_streams {
let group = group_map.get_mut(&stream.get_category_id()).unwrap_or(&mut unknown_grp);
let category_name = &group.category_name;
let stream_url = create_xtream_url(xtream_cluster, url, username, password, &stream, live_stream_use_prefix, live_stream_without_extension);
let item_type = PlaylistItemType::from(xtream_cluster);
let mut item = PlaylistItem {
let item = PlaylistItem {
header: PlaylistItemHeader {
id: stream.get_stream_id().intern(),
uuid: generate_playlist_uuid(&input_name, &stream.get_stream_id().to_string(), item_type, &stream_url),
@@ -197,14 +217,25 @@ pub async fn parse_xtream(input: &ConfigInput,
..Default::default()
},
};
item.header.source_ordinal = ord_counter;
group.add(item);
}
if !unknown_grp.channels.is_empty() {
let unknown_key = next_group_map_key(&group_map);
unknown_grp.category_id = unknown_key;
group_map.insert(unknown_key, unknown_grp);
}
let has_channels = !unknown_grp.channels.is_empty();
if has_channels {
group_map.insert(0, unknown_grp);
// Assign source_ordinal in category-list order (primary)
// with stream-list position within each category (secondary).
// The IndexMap preserves the provider's category ordering,
// so a single sequential pass produces the correct ordinals.
let mut ordinal: u32 = 0;
for category in group_map.values_mut() {
for channel in &mut category.channels {
ordinal += 1;
channel.header.source_ordinal = ordinal;
}
}
Ok(Some(group_map.values().filter(|category| !category.channels.is_empty())
@@ -251,42 +282,62 @@ where
let group_map: IndexMap<u32, Arc<str>> = xtream_categories.iter().map(|c| (c.category_id, c.category_name.clone())).collect();
let unknown_group_name = "Unknown".intern();
// Category position lookup for source_ordinal: streams are ordered by
// category-list position (primary) then arrival order within that
// category (secondary). We encode both into a single u32 so the
// downstream sort-by-source_ordinal reproduces the provider's category
// ordering without any extra fields or a second sort pass.
//
// Layout: cat_position * CAT_BUCKET + within_cat_counter
// With CAT_BUCKET = 100_000 this supports up to ~42_900 categories
// with up to 100 000 streams each — well beyond real-world sizes.
let cat_order: HashMap<u32, u32> = group_map.keys().enumerate()
.map(|(idx, &cat_id)| (cat_id, u32::try_from(idx).unwrap_or(u32::MAX)))
.collect();
let unknown_cat_pos = u32::try_from(cat_order.len()).unwrap_or(u32::MAX);
spawn_blocking(move || {
let reader = tokio_util::io::SyncIoBridge::new(streams);
let mut deserializer = serde_json::Deserializer::from_reader(reader);
let mut source_ordinal = 0u32;
let mut cat_counters: HashMap<u32, u32> = HashMap::new();
let mut cat_source_ordinal = |cat_id: u32| -> u32 {
let cat_pos = cat_order.get(&cat_id).copied().unwrap_or(unknown_cat_pos);
let counter = cat_counters.entry(cat_pos).or_insert(0);
*counter += 1;
cat_pos.saturating_mul(CAT_BUCKET).saturating_add(*counter)
};
match xtream_cluster {
XtreamCluster::Live => {
let mut on_stream = |stream: LiveStreamProperties| {
source_ordinal += 1;
let ordinal = cat_source_ordinal(stream.category_id);
let stream_prop = StreamProperties::Live(Box::new(stream));
process_stream_item(&input_name, &url, &username, &password,
xtream_cluster, &group_map, &unknown_group_name,
stream_prop, &mut on_item, live_stream_use_prefix, live_stream_without_extension, source_ordinal)
stream_prop, &mut on_item, live_stream_use_prefix, live_stream_without_extension, ordinal)
};
let visitor = XtreamItemVisitor { on_item: &mut on_stream, _marker: std::marker::PhantomData };
deserializer.deserialize_any(visitor).map_err(|e| notify_err!("JSON parse error: {e}"))?;
}
XtreamCluster::Video => {
let mut on_stream = |stream: VideoStreamProperties| {
source_ordinal += 1;
let ordinal = cat_source_ordinal(stream.category_id);
let stream_prop = StreamProperties::Video(Box::new(stream));
process_stream_item(&input_name, &url, &username, &password,
xtream_cluster, &group_map, &unknown_group_name,
stream_prop, &mut on_item, live_stream_use_prefix, live_stream_without_extension, source_ordinal)
stream_prop, &mut on_item, live_stream_use_prefix, live_stream_without_extension, ordinal)
};
let visitor = XtreamItemVisitor { on_item: &mut on_stream, _marker: std::marker::PhantomData };
deserializer.deserialize_any(visitor).map_err(|e| notify_err!("JSON parse error: {e}"))?;
}
XtreamCluster::Series => {
let mut on_stream = |stream: SeriesStreamProperties| {
source_ordinal += 1;
let ordinal = cat_source_ordinal(stream.category_id);
let stream_prop = StreamProperties::Series(Box::new(stream));
process_stream_item(&input_name, &url, &username, &password,
xtream_cluster, &group_map, &unknown_group_name,
stream_prop, &mut on_item, live_stream_use_prefix, live_stream_without_extension, source_ordinal)
stream_prop, &mut on_item, live_stream_use_prefix, live_stream_without_extension, ordinal)
};
let visitor = XtreamItemVisitor { on_item: &mut on_stream, _marker: std::marker::PhantomData };
deserializer.deserialize_any(visitor).map_err(|e| notify_err!("JSON parse error: {e}"))?;
@@ -390,17 +441,40 @@ where
#[cfg(test)]
mod tests {
use super::parse_xtream_series_info;
use super::CAT_BUCKET;
use super::{parse_xtream, parse_xtream_series_info, parse_xtream_streaming};
use crate::processing::parser::xtream::map_to_xtream_streams;
use crate::model::ConfigInput;
use crate::utils::async_file_reader;
use crate::utils::{async_file_reader, request::DynReader};
use shared::model::{
UUIDType,
SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamProperties,
XtreamCluster, XtreamSeriesInfo,
XtreamCluster, XtreamPlaylistItem, XtreamSeriesInfo,
};
use shared::utils::Internable;
use std::fs;
use std::sync::{Arc, Mutex};
use tokio::io::AsyncWriteExt;
fn make_reader(content: &str) -> DynReader {
let (mut writer, reader) = tokio::io::duplex(4096);
let bytes = content.as_bytes().to_vec();
tokio::spawn(async move {
writer.write_all(&bytes).await.unwrap();
writer.shutdown().await.unwrap();
});
Box::pin(reader)
}
fn test_input() -> ConfigInput {
ConfigInput {
name: "input".intern(),
url: "http://provider.example".to_string(),
username: Some("user".to_string()),
password: Some("pass".to_string()),
..ConfigInput::default()
}
}
#[test]
fn test_read_json_file_into_struct() {
@@ -497,13 +571,7 @@ mod tests {
#[test]
fn test_parse_xtream_series_info_keeps_zero_parent_source_ordinal() {
let input = ConfigInput {
name: "input".intern(),
url: "http://provider.example".to_string(),
username: Some("user".to_string()),
password: Some("pass".to_string()),
..ConfigInput::default()
};
let input = test_input();
let episode: SeriesStreamDetailEpisodeProperties = serde_json::from_str(
r#"{"id":101,"episode_num":1,"season":1,"title":"S01E01","container_extension":"mp4"}"#,
@@ -533,4 +601,126 @@ mod tests {
assert_eq!(episodes[0].header.source_ordinal, 0);
}
#[tokio::test]
async fn test_parse_xtream_streaming_source_ordinal_uses_category_then_channel_position() {
let categories = r#"
[
{"category_id":"20","category_name":"News"},
{"category_id":"10","category_name":"Sports"}
]
"#;
let streams = r#"
[
{"name":"sports-1","stream_id":101,"category_id":"10","added":"0"},
{"name":"news-1","stream_id":201,"category_id":"20","added":"0"},
{"name":"sports-2","stream_id":102,"category_id":"10","added":"0"},
{"name":"news-2","stream_id":202,"category_id":"20","added":"0"}
]
"#;
let items: Arc<Mutex<Vec<XtreamPlaylistItem>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&items);
parse_xtream_streaming(
&test_input(),
XtreamCluster::Live,
make_reader(categories),
make_reader(streams),
move |item| {
sink.lock().unwrap().push(item);
Ok(())
},
)
.await
.unwrap();
let mut items = items.lock().unwrap().clone();
items.sort_by_key(|item| item.source_ordinal);
let names: Vec<&str> = items.iter().map(|item| item.name.as_ref()).collect();
assert_eq!(names, vec!["news-1", "news-2", "sports-1", "sports-2"]);
assert_eq!(items[0].source_ordinal, 1);
assert_eq!(items[1].source_ordinal, 2);
assert_eq!(items[2].source_ordinal, CAT_BUCKET + 1);
assert_eq!(items[3].source_ordinal, CAT_BUCKET + 2);
}
#[tokio::test]
async fn test_parse_xtream_groups_multiple_unknown_categories_into_unknown_group() {
let categories = r#"
[
{"category_id":"20","category_name":"Known"}
]
"#;
let streams = r#"
[
{"name":"unknown-999","stream_id":301,"category_id":"999","added":"0"},
{"name":"known-1","stream_id":201,"category_id":"20","added":"0"},
{"name":"unknown-888","stream_id":302,"category_id":"888","added":"0"}
]
"#;
let groups = parse_xtream(
&test_input(),
XtreamCluster::Live,
make_reader(categories),
make_reader(streams),
)
.await
.unwrap()
.unwrap();
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].title.as_ref(), "Known");
assert_eq!(groups[0].channels.len(), 1);
assert_eq!(groups[0].channels[0].header.name.as_ref(), "known-1");
assert_eq!(groups[0].channels[0].header.source_ordinal, 1);
assert_eq!(groups[1].title.as_ref(), "Unknown");
let unknown_names: Vec<&str> = groups[1]
.channels
.iter()
.map(|item| item.header.name.as_ref())
.collect();
assert_eq!(unknown_names, vec!["unknown-999", "unknown-888"]);
assert_eq!(groups[1].channels[0].header.source_ordinal, 2);
assert_eq!(groups[1].channels[1].header.source_ordinal, 3);
}
#[tokio::test]
async fn test_parse_xtream_keeps_real_category_zero_and_appends_unknown_group() {
let categories = r#"
[
{"category_id":"0","category_name":"Provider Zero"}
]
"#;
let streams = r#"
[
{"name":"zero-1","stream_id":201,"category_id":"0","added":"0"},
{"name":"unknown-1","stream_id":301,"category_id":"999","added":"0"}
]
"#;
let groups = parse_xtream(
&test_input(),
XtreamCluster::Live,
make_reader(categories),
make_reader(streams),
)
.await
.unwrap()
.unwrap();
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].id, 0);
assert_eq!(groups[0].title.as_ref(), "Provider Zero");
assert_eq!(groups[0].channels[0].header.name.as_ref(), "zero-1");
assert_eq!(groups[0].channels[0].header.source_ordinal, 1);
assert_eq!(groups[1].title.as_ref(), "Unknown");
assert_ne!(groups[1].id, 0);
assert_eq!(groups[1].channels[0].header.name.as_ref(), "unknown-1");
assert_eq!(groups[1].channels[0].header.source_ordinal, 2);
}
}
@@ -43,7 +43,7 @@ mod tests {
use super::ProbeHandleGuard;
use crate::{
api::model::{ActiveProviderManager, EventManager},
model::{AppConfig, Config, ConfigInput, SourcesConfig},
model::{AppConfig, Config, ConfigInput, MediaToolCapabilities, SourcesConfig},
utils::FileLockManager,
};
use arc_swap::{ArcSwap, ArcSwapOption};
@@ -93,7 +93,7 @@ mod tests {
custom_stream_response: Arc::new(ArcSwapOption::default()),
access_token_secret: [0; 32],
encrypt_secret: [0; 16],
ffprobe_available: Arc::default(),
media_tools: Arc::new(MediaToolCapabilities::new()),
}
}
@@ -3,8 +3,8 @@ use crate::model::ConfigInput;
use crate::model::{AppConfig};
use crate::processing::processor::{select_cancel_token, ProbeHandleGuard};
use crate::repository::{get_input_m3u_playlist_file_path, get_input_storage_path, get_input_local_library_playlist_file_path, xtream_get_file_path, BPlusTreeUpdate};
use crate::utils::{debug_if_enabled, ffmpeg};
use crate::utils::ffmpeg::{ProbeFailureKind, ProbeUrlOutcome};
use crate::utils::debug_if_enabled;
use crate::utils::ffmpeg::{FfmpegExecutor, ProbeFailureKind, ProbeUrlOutcome};
use log::{info, warn};
use shared::error::TuliproxError;
use shared::model::{EpisodeStreamProperties, InputType, PlaylistItemType, StreamProperties, VideoStreamDetailProperties, VideoStreamProperties, LiveStreamProperties, M3uPlaylistItem, XtreamCluster, XtreamPlaylistItem};
@@ -124,7 +124,7 @@ pub async fn update_generic_stream_metadata(
acquired_handle.as_ref().and_then(ProbeHandleGuard::handle),
active_handle,
);
let probe_data = ffmpeg::probe_url_with_cancel(
let probe_data = FfmpegExecutor::new().probe_url_with_cancel(
&probe_url,
user_agent.as_deref(),
analyze_duration,
+2 -2
View File
@@ -5,7 +5,7 @@ use crate::model::{AppConfig, ConfigInput, ConfigInputFlags};
use shared::model::{LiveStreamProperties, StreamProperties, XtreamCluster, XtreamPlaylistItem};
use crate::repository::{get_input_storage_path, persist_input_live_info, BPlusTreeQuery, xtream_get_file_path};
use crate::utils::{debug_if_enabled};
use crate::utils::ffmpeg::{ProbeFailureKind, ProbeUrlOutcome};
use crate::utils::ffmpeg::{FfmpegExecutor, ProbeFailureKind, ProbeUrlOutcome};
use log::{debug, warn};
use crate::processing::parser::xtream::create_xtream_url;
use crate::api::model::{ActiveProviderManager, ProviderHandle, ProviderIdType};
@@ -133,7 +133,7 @@ pub async fn update_live_stream_metadata(
let mut success = false;
let mut not_found = false;
match crate::utils::ffmpeg::probe_url(
match FfmpegExecutor::new().probe_url(
&stream_url,
user_agent.as_deref(),
analyze_duration,
@@ -19,7 +19,7 @@ use crate::repository::{
get_input_storage_path, persist_input_series_info_batch, MemoryPlaylistSource, PlaylistSource,
};
use crate::repository::{xtream_get_file_path, BPlusTreeQuery};
use crate::utils::ffmpeg::{ProbeFailureKind, ProbeUrlOutcome};
use crate::utils::ffmpeg::{FfmpegExecutor, ProbeFailureKind, ProbeUrlOutcome};
use crate::utils::{debug_if_enabled, xtream};
use log::{debug, error, info, log_enabled, trace, warn, Level};
use parking_lot::Mutex;
@@ -924,7 +924,7 @@ pub async fn update_series_metadata(
temp_handle.as_ref().and_then(ProbeHandleGuard::handle),
active_handle,
);
match crate::utils::ffmpeg::probe_url_with_cancel(
match FfmpegExecutor::new().probe_url_with_cancel(
&episode_url,
user_agent.as_deref(),
probe_settings.analyze_duration_micros,
@@ -17,7 +17,7 @@ use crate::ptt::ptt_parse_title;
use crate::repository::persist_input_vod_info;
use crate::repository::persist_input_vod_info_batch;
use crate::repository::{xtream_get_file_path, BPlusTreeQuery};
use crate::utils::ffmpeg::{ProbeFailureKind, ProbeUrlOutcome};
use crate::utils::ffmpeg::{FfmpegExecutor, ProbeFailureKind, ProbeUrlOutcome};
use crate::utils::{debug_if_enabled, trace_if_enabled, xtream};
use log::{debug, error, info, log_enabled, trace, warn, Level};
use parking_lot::Mutex;
@@ -841,7 +841,7 @@ pub async fn update_vod_metadata(
temp_handle.as_ref().and_then(ProbeHandleGuard::handle),
active_handle,
);
match crate::utils::ffmpeg::probe_url_with_cancel(
match FfmpegExecutor::new().probe_url_with_cancel(
&stream_url,
user_agent.as_deref(),
analyze_duration,
+2 -1
View File
@@ -368,6 +368,7 @@ pub async fn user_get_bouquet_filter(config: &Config, username: &str, category_i
#[cfg(test)]
mod tests {
use super::*;
use crate::model::MediaToolCapabilities;
use crate::utils::FileLockManager;
use arc_swap::{ArcSwap, ArcSwapAny};
use shared::model::{ConfigPaths, ProxyType, ProxyUserStatus};
@@ -473,7 +474,7 @@ mod tests {
custom_stream_response: Arc::new(ArcSwapAny::default()),
access_token_secret: Default::default(),
encrypt_secret: Default::default(),
ffprobe_available: Arc::default(),
media_tools: Arc::new(MediaToolCapabilities::new()),
};
let target_user = vec![user];
let _ = store_api_user(&cfg, &target_user).await;
-1
View File
@@ -1 +0,0 @@
pub mod lru_cache;
+306 -135
View File
@@ -2,11 +2,14 @@ use crate::model::ProxyConfig;
use log::{debug, warn};
use serde_json::Value;
use shared::model::MediaQuality;
use shared::utils::sanitize_sensitive_info;
use shared::utils::{default_thumbnail_height, default_thumbnail_width, sanitize_sensitive_info};
use std::path::Path;
use std::time::Duration;
use tokio::process::Command;
use url::Url;
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProbeFailureKind {
NotFound,
@@ -19,12 +22,220 @@ pub enum ProbeUrlOutcome {
Failed(ProbeFailureKind),
}
// Checks if ffprobe is available in the system path
pub async fn check_ffprobe_availability() -> bool {
match Command::new("ffprobe").arg("-version").output().await {
Ok(output) => output.status.success(),
Err(_) => false,
#[derive(Debug, Clone, Copy, Default)]
pub struct FfmpegExecutor;
impl FfmpegExecutor {
#[must_use]
pub const fn new() -> Self { Self }
/// Checks if the system `ffmpeg` binary is available.
pub async fn check_ffmpeg_availability(&self) -> bool {
self.check_binary_availability("ffmpeg").await
}
// Checks if ffprobe is available in the system path
pub async fn check_ffprobe_availability(&self) -> bool {
self.check_binary_availability("ffprobe").await
}
/// Extracts a JPEG thumbnail from a local file.
/// Attempts a frame at 180s first and falls back to 0s for short videos.
pub async fn create_thumbnail(&self, input_path: &str, width: u32, height: u32) -> Result<Vec<u8>, String> {
let temp_dir = tempfile::tempdir()
.map_err(|e| format!("Failed to create temp dir: {e}"))?;
let output_path = temp_dir.path().join("thumb.jpg");
let scale_filter = build_thumbnail_scale_filter(width, height);
let output = self.run_ffmpeg_with_timeout(&build_thumbnail_args(input_path, &output_path, &scale_filter, 180))
.await
.map_err(|e| format!("Failed to run ffmpeg: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("Output file is empty") || stderr.contains("nothing was encoded") {
debug!("Video shorter than 180s, retrying at position 0: {input_path}");
let retry = self.run_ffmpeg_with_timeout(&build_thumbnail_args(input_path, &output_path, &scale_filter, 0))
.await
.map_err(|e| format!("Failed to run ffmpeg retry: {e}"))?;
if !retry.status.success() {
let retry_stderr = String::from_utf8_lossy(&retry.stderr);
return Err(format!("ffmpeg failed at position 0: {retry_stderr}"));
}
} else {
return Err(format!("ffmpeg failed: {stderr}"));
}
}
tokio::fs::read(&output_path)
.await
.map_err(|e| format!("Failed to read thumbnail: {e}"))
}
pub async fn probe_url(
&self,
url: &str,
user_agent: Option<&str>,
analyze_duration: u64,
probe_size: u64,
timeout_secs: u64,
proxy_cfg: Option<&ProxyConfig>,
) -> ProbeUrlOutcome {
// Determine timeout: Ensure it's at least as long as the analyze duration + buffer,
// but respect the user setting if it's longer.
let analyze_overhead = Duration::from_micros(analyze_duration) + Duration::from_secs(5);
let config_timeout = Duration::from_secs(timeout_secs);
let timeout_val = std::cmp::max(analyze_overhead, config_timeout);
let mut command = Command::new("ffprobe");
// Ensure the child process is killed if this future is dropped (e.g. by connection preemption)
command.kill_on_drop(true);
command
.arg("-v").arg("error")
.arg("-show_streams")
.arg("-of").arg("json")
.arg("-analyzeduration").arg(analyze_duration.to_string())
.arg("-probesize").arg(probe_size.to_string());
apply_proxy_to_ffprobe(&mut command, proxy_cfg);
if let Some(ua) = user_agent {
command.arg("-user_agent").arg(ua);
}
command.arg(url);
let output_result = tokio::time::timeout(timeout_val, command.output()).await;
match output_result {
Ok(Ok(output)) => {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
debug!("ffprobe failed for {}: {}", sanitize_sensitive_info(url), sanitize_sensitive_info(&stderr));
if is_not_found_probe_error(&stderr) {
return ProbeUrlOutcome::Failed(ProbeFailureKind::NotFound);
}
return ProbeUrlOutcome::Failed(ProbeFailureKind::Other);
}
if let Ok(json) = serde_json::from_slice::<Value>(&output.stdout) {
if let Some(stream_list) = json.get("streams").and_then(Value::as_array) {
let mut video_stream: Option<&Value> = None;
let mut audio_stream: Option<&Value> = None;
for stream in stream_list {
let codec_type = stream.get("codec_type").and_then(Value::as_str);
if video_stream.is_none()
&& (codec_type == Some("video")
|| (codec_type.is_none()
&& (stream.get("width").is_some() || stream.get("height").is_some())))
&& !is_attached_pic(stream)
{
video_stream = Some(stream);
} else if audio_stream.is_none()
&& (codec_type == Some("audio")
|| (codec_type.is_none()
&& (stream.get("channels").is_some()
|| stream.get("channel_layout").is_some())))
{
audio_stream = Some(stream);
}
if video_stream.is_some() && audio_stream.is_some() {
break;
}
}
if video_stream.is_some() || audio_stream.is_some() {
let video_str = video_stream.map(Value::to_string);
let audio_str = audio_stream.map(Value::to_string);
let mq = MediaQuality::from_ffprobe_info(audio_str.as_deref(), video_str.as_deref());
if let Some(quality) = mq {
return ProbeUrlOutcome::Success(
quality,
video_stream.cloned(),
audio_stream.cloned(),
);
}
}
}
} else {
warn!("Failed to parse ffprobe json output for {}", sanitize_sensitive_info(url));
}
}
Ok(Err(e)) => {
warn!("ffprobe execution failed for {}: {}", sanitize_sensitive_info(url), e);
}
Err(_) => {
warn!("ffprobe timed out after {:?} for {}", timeout_val, sanitize_sensitive_info(url));
}
}
ProbeUrlOutcome::Failed(ProbeFailureKind::Other)
}
/// Wrapper around [`Self::probe_url`] that races the probe against an optional cancellation token.
#[allow(clippy::too_many_arguments)]
pub async fn probe_url_with_cancel(
&self,
url: &str,
user_agent: Option<&str>,
analyze_duration: u64,
probe_size: u64,
timeout_secs: u64,
proxy_cfg: Option<&ProxyConfig>,
cancel_token: Option<&tokio_util::sync::CancellationToken>,
) -> ProbeUrlOutcome {
if let Some(token) = cancel_token {
tokio::select! {
biased;
() = token.cancelled() => {
warn!("Probe preempted for {}", shared::utils::sanitize_sensitive_info(url));
ProbeUrlOutcome::Failed(ProbeFailureKind::Cancelled)
}
result = self.probe_url(url, user_agent, analyze_duration, probe_size, timeout_secs, proxy_cfg) => result,
}
} else {
self.probe_url(url, user_agent, analyze_duration, probe_size, timeout_secs, proxy_cfg).await
}
}
async fn check_binary_availability(&self, binary: &str) -> bool {
let mut command = Command::new(binary);
command
.arg("-version")
.kill_on_drop(true);
match tokio::time::timeout(FFMPEG_TIMEOUT, command.output()).await {
Ok(Ok(output)) => output.status.success(),
Ok(Err(_)) | Err(_) => false,
}
}
async fn run_ffmpeg_with_timeout(&self, args: &[String]) -> Result<std::process::Output, String> {
let child = Command::new("ffmpeg")
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| format!("Failed to spawn ffmpeg: {e}"))?;
tokio::time::timeout(FFMPEG_TIMEOUT, child.wait_with_output())
.await
.map_err(|_| format_ffmpeg_timeout_error(args))?
.map_err(|e| e.to_string())
}
}
fn format_ffmpeg_timeout_error(args: &[String]) -> String {
let summary = args.join(" ");
format!(
"Timed out running ffmpeg after {}s: {summary}",
FFMPEG_TIMEOUT.as_secs()
)
}
fn build_ffprobe_proxy_url(proxy_cfg: &ProxyConfig) -> Option<String> {
@@ -79,144 +290,48 @@ fn is_attached_pic(stream: &Value) -> bool {
fn is_not_found_probe_error(stderr: &str) -> bool {
let normalized = stderr.to_ascii_lowercase();
normalized.contains("404") || normalized.contains("not found")
[
"http error 404",
"404 not found",
"http/1.1 404",
"http/2 404",
"server returned 404",
]
.iter()
.any(|needle| normalized.contains(needle))
}
pub async fn probe_url(
url: &str,
user_agent: Option<&str>,
analyze_duration: u64,
probe_size: u64,
timeout_secs: u64,
proxy_cfg: Option<&ProxyConfig>,
) -> ProbeUrlOutcome {
// Determine timeout: Ensure it's at least as long as the analyze duration + buffer,
// but respect the user setting if it's longer.
let analyze_overhead = Duration::from_micros(analyze_duration) + Duration::from_secs(5);
let config_timeout = Duration::from_secs(timeout_secs);
let timeout_val = std::cmp::max(analyze_overhead, config_timeout);
let mut command = Command::new("ffprobe");
// Ensure the child process is killed if this future is dropped (e.g. by connection preemption)
command.kill_on_drop(true);
command
.arg("-v").arg("error")
.arg("-show_streams") // Get all streams info
.arg("-of").arg("json")
// Optimization for network streams
.arg("-analyzeduration").arg(analyze_duration.to_string())
.arg("-probesize").arg(probe_size.to_string());
apply_proxy_to_ffprobe(&mut command, proxy_cfg);
if let Some(ua) = user_agent {
command.arg("-user_agent").arg(ua);
}
command.arg(url);
let output_result = tokio::time::timeout(timeout_val, command.output()).await;
match output_result {
Ok(Ok(output)) => {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
debug!("ffprobe failed for {}: {}", sanitize_sensitive_info(url), sanitize_sensitive_info(&stderr));
if is_not_found_probe_error(&stderr) {
return ProbeUrlOutcome::Failed(ProbeFailureKind::NotFound);
}
return ProbeUrlOutcome::Failed(ProbeFailureKind::Other);
}
if let Ok(json) = serde_json::from_slice::<Value>(&output.stdout) {
if let Some(stream_list) = json.get("streams").and_then(Value::as_array) {
// Single-pass stream detection: prefer codec_type, fall back to structural hints.
let mut video_stream: Option<&Value> = None;
let mut audio_stream: Option<&Value> = None;
for stream in stream_list {
let codec_type = stream.get("codec_type").and_then(Value::as_str);
if video_stream.is_none()
&& (codec_type == Some("video")
|| (codec_type.is_none()
&& (stream.get("width").is_some() || stream.get("height").is_some())))
&& !is_attached_pic(stream)
{
video_stream = Some(stream);
} else if audio_stream.is_none()
&& (codec_type == Some("audio")
|| (codec_type.is_none()
&& (stream.get("channels").is_some()
|| stream.get("channel_layout").is_some())))
{
audio_stream = Some(stream);
}
if video_stream.is_some() && audio_stream.is_some() {
break;
}
}
if video_stream.is_some() || audio_stream.is_some() {
// Materialize strings only for the selected streams.
let video_str = video_stream.map(Value::to_string);
let audio_str = audio_stream.map(Value::to_string);
let mq = MediaQuality::from_ffprobe_info(audio_str.as_deref(), video_str.as_deref());
if let Some(quality) = mq {
return ProbeUrlOutcome::Success(
quality,
video_stream.cloned(),
audio_stream.cloned(),
);
}
}
}
} else {
warn!("Failed to parse ffprobe json output for {}", sanitize_sensitive_info(url));
}
}
Ok(Err(e)) => {
warn!("ffprobe execution failed for {}: {}", sanitize_sensitive_info(url), e);
}
Err(_) => {
warn!("ffprobe timed out after {:?} for {}", timeout_val, sanitize_sensitive_info(url));
}
}
ProbeUrlOutcome::Failed(ProbeFailureKind::Other)
fn build_thumbnail_scale_filter(width: u32, height: u32) -> String {
let w = if width < 1 { default_thumbnail_width() } else { width };
let h = if height < 1 { default_thumbnail_height() } else { height };
format!(
"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
)
}
/// Wrapper around [`probe_url`] that races the probe against an optional cancellation token.
/// When the token fires, the probe future is dropped (`kill_on_drop` kills the ffprobe process)
/// and `ProbeUrlOutcome::Failed(Cancelled)` is returned immediately.
pub async fn probe_url_with_cancel(
url: &str,
user_agent: Option<&str>,
analyze_duration: u64,
probe_size: u64,
timeout_secs: u64,
proxy_cfg: Option<&ProxyConfig>,
cancel_token: Option<&tokio_util::sync::CancellationToken>,
) -> ProbeUrlOutcome {
if let Some(token) = cancel_token {
tokio::select! {
biased;
() = token.cancelled() => {
warn!("Probe preempted for {}", shared::utils::sanitize_sensitive_info(url));
ProbeUrlOutcome::Failed(ProbeFailureKind::Cancelled)
}
result = probe_url(url, user_agent, analyze_duration, probe_size, timeout_secs, proxy_cfg) => result,
}
} else {
probe_url(url, user_agent, analyze_duration, probe_size, timeout_secs, proxy_cfg).await
}
fn build_thumbnail_args(input_path: &str, output_path: &Path, scale_filter: &str, seek_seconds: u32) -> Vec<String> {
vec![
"-ss".to_string(),
seek_seconds.to_string(),
"-i".to_string(),
input_path.to_string(),
"-frames:v".to_string(),
"1".to_string(),
"-vf".to_string(),
scale_filter.to_string(),
"-q:v".to_string(),
"1".to_string(),
"-y".to_string(),
output_path.to_string_lossy().into_owned(),
]
}
#[cfg(test)]
mod tests {
use super::build_ffprobe_proxy_url;
use super::{build_ffprobe_proxy_url, build_thumbnail_args, build_thumbnail_scale_filter, format_ffmpeg_timeout_error, FFMPEG_TIMEOUT};
use crate::model::ProxyConfig;
use shared::utils::{default_thumbnail_height, default_thumbnail_width};
use std::path::Path;
#[test]
fn build_ffprobe_proxy_url_injects_credentials() {
@@ -239,4 +354,60 @@ mod tests {
let resolved = build_ffprobe_proxy_url(&proxy_cfg).expect("proxy url should parse");
assert!(resolved.contains("bob:pass@proxy.local:1080"));
}
#[test]
fn build_thumbnail_scale_filter_formats_dimensions() {
let filter = build_thumbnail_scale_filter(320, 180);
assert_eq!(filter, "scale=320:180:force_original_aspect_ratio=increase,crop=320:180");
}
#[test]
fn build_thumbnail_scale_filter_uses_defaults_for_zero_dimensions() {
let filter = build_thumbnail_scale_filter(0, 0);
let expected = build_thumbnail_scale_filter(default_thumbnail_width(), default_thumbnail_height());
assert_eq!(filter, expected);
}
#[test]
fn build_thumbnail_args_encodes_expected_ffmpeg_call() {
let args = build_thumbnail_args("/tmp/in.mkv", Path::new("/tmp/thumb.jpg"), "scale=320:180", 180);
assert_eq!(
args,
vec![
"-ss",
"180",
"-i",
"/tmp/in.mkv",
"-frames:v",
"1",
"-vf",
"scale=320:180",
"-q:v",
"1",
"-y",
"/tmp/thumb.jpg",
]
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>()
);
}
#[test]
fn format_ffmpeg_timeout_error_includes_binary_timeout_and_args() {
let msg = format_ffmpeg_timeout_error(&["-ss".to_string(), "180".to_string(), "-i".to_string(), "/tmp/in.mkv".to_string()]);
assert!(msg.contains("ffmpeg"));
assert!(msg.contains(&FFMPEG_TIMEOUT.as_secs().to_string()));
assert!(msg.contains("-ss 180 -i /tmp/in.mkv"));
}
#[test]
fn is_not_found_probe_error_only_matches_http_404_markers() {
assert!(super::is_not_found_probe_error("HTTP error 404 Not Found"));
assert!(super::is_not_found_probe_error("Server returned 404 Not Found"));
assert!(super::is_not_found_probe_error("HTTP/1.1 404 Not Found"));
assert!(!super::is_not_found_probe_error("host not found"));
assert!(!super::is_not_found_probe_error("file not found"));
assert!(!super::is_not_found_probe_error("protocol handler not found"));
}
}
+2 -3
View File
@@ -1,6 +1,6 @@
use crate::api::model::AppState;
use crate::model::Config;
use crate::model::{ApiProxyConfig, AppConfig, SourcesConfig};
use crate::model::{ApiProxyConfig, AppConfig, MediaToolCapabilities, SourcesConfig};
use crate::repository::{
csv_read_inputs, csv_write_inputs, get_api_user_db_path, is_csv_file, load_api_user,
};
@@ -29,7 +29,6 @@ use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tokio::sync::OnceCell;
use shared::concat_string;
use crate::utils::request::{is_uri};
use url::Url;
@@ -581,7 +580,7 @@ pub async fn read_initial_app_config(
custom_stream_response: Arc::new(ArcSwapAny::default()),
access_token_secret: generate_default_access_secret(),
encrypt_secret: generate_default_encrypt_secret(),
ffprobe_available: Arc::new(OnceCell::new())
media_tools: Arc::new(MediaToolCapabilities::new()),
};
app_config.prepare(include_computed)?;
//print_info(&app_config);
+2
View File
@@ -14,6 +14,7 @@ mod db_viewer;
mod epg_parser;
mod ordinal;
pub mod ffmpeg;
mod lru_cache;
pub use self::binary_utils::*;
pub use self::logging::*;
@@ -24,6 +25,7 @@ pub use self::db_viewer::*;
pub use shared::utils::*;
pub use self::epg_parser::*;
pub use self::ordinal::*;
pub use self::lru_cache::*;
#[macro_export]
macro_rules! debug_if_enabled {
+2 -2
View File
@@ -1561,7 +1561,7 @@ mod tests {
strip_sensitive_headers_for_cross_origin_redirect,
};
use crate::{
model::{AppConfig, Config, ConfigProvider, ResourceRetryConfig, ReverseProxyConfig, SourcesConfig},
model::{AppConfig, Config, ConfigProvider, MediaToolCapabilities, ResourceRetryConfig, ReverseProxyConfig, SourcesConfig},
utils::{FileLockManager, DEFAULT_USER_AGENT}
};
use arc_swap::{ArcSwap, ArcSwapOption};
@@ -1607,7 +1607,7 @@ mod tests {
custom_stream_response: Arc::new(ArcSwapOption::default()),
access_token_secret: [0; 32],
encrypt_secret: [0; 16],
ffprobe_available: Arc::default(),
media_tools: Arc::new(MediaToolCapabilities::new()),
})
}
+320 -1
View File
@@ -119,6 +119,7 @@ in your `config.yml`. Without it, these fields are purely cosmetic!
| `proxy` | Enum | No | `redirect` | Defines the proxy mode for this user (see [proxy modes](#proxy-modes-proxy) below). |
| `server` | String | No | `default` | Which server block (host/port) is rendered into the playlist for this user. |
| `epg_timeshift` | String | No | `None` | Shifts EPG times for users in different time zones. Formats supported: `[-+]hh:mm` or `TimeZone`. Examples: `-2:30` (minus 2h30m), `1:45` (1h45m), `+0:15` (15m), `2` (2h), `:30` (30m), `:3` (3m), `Europe/Paris`, `America/New_York`. Only applies when `epg_url` is configured. |
| `epg_request_timeshift` | String | No | `None` | Shifts EPG times for users in different time zones specifically to adjust catchup requests |
| `max_connections` | Int | No | `0` | Hard limit of concurrent streams for *this* user. `0` = Unlimited. **Requires** `user_access_control: true` in `config.yml` to be enforced. |
| `status` | Enum | No | `Active` | Possible values: `Active`, `Trial`, `Expired`, `Banned`, `Disabled`, `Pending`. **Requires** `user_access_control: true` in `config.yml` to block non-active streaming. |
| `exp_date` | UnixTs | No | `None` | Locks the user out after this Unix timestamp. **Requires** `user_access_control: true` in `config.yml` to be enforced. |
@@ -137,7 +138,7 @@ This is the most crucial field governing traffic flow for the user. When to use
* *Tradeoff:* **No** connection limits, buffering, bandwidth throttling, or custom fallback videos are applied!
* **`reverse`**: Tuliprox downloads the video stream from the provider onto your server and pipes it to the client.
* *When to use:* This is required for connection limits, fallback videos, caching, bandwidth throttling, and shared
streams to function.
streams to function.
* **Partial Syntax**: You can mix and match! `reverse[live]` forces Live-TV through Tuliprox
(allowing shared streams) but redirects VODs (saving bandwidth).
`reverse[live,vod]` routes everything except Series episodes through Tuliprox.
@@ -173,6 +174,324 @@ preempted/killed if any real user needs the slot.)*
---
### EPG Timeshift Configuration Guide
#### What is an EPG Timeshift?
**EPG (Electronic Program Guide)** timeshift allows you to adjust TV program times to match your local time zone. This
is especially useful when:
* You live in a different time zone than your IPTV provider
* You want to view programs as if they were aired at a different time
* Your EPG data is in one time zone, but you need it displayed in another
---
#### The Two EPG Timeshift Fields
When configuring users in Tuliprox, you'll find two separate fields for EPG timeshift:
| Field | Purpose | When to Use |
|-------------------------|-----------------------------------------------------------------|--------------------------------------------------------------------|
| `epg_timeshift` | Shifts EPG times for **XMLTV/EPG requests** (regular EPG files) | Use when you want ALL EPG times globally shifted for this user |
| `epg_request_timeshift` | Adjusts client-provided time ranges for **XTream Catchup API** | Use when you need to shift catchup time requests (start/end times) |
---
#### When to Use Which Field?
##### Use `epg_timeshift` for
✅ **XMLTV EPG Requests**
* When clients request EPG via `/xmltv.php` or `/epg` endpoints
* When serving EPG files to applications
* When you want ALL program times adjusted to your time zone
**Example:** You're in Paris (UTC+2) and your provider is in UTC. All EPG times should be 2 hours earlier.
---
##### Use `epg_request_timeshift` for
✅ **XTream Catchup API**
* When clients request catchup via `/timeshift` or `/streaming/timeshift.php`
* When clients provide their own time ranges (`start`, `end`, `duration`)
* When you need to shift those client-provided times to match your needs
**Example:** A client requests catchup from 14:00-16:00. You want these times shifted to match your local time zone.
---
##### Key Difference
| Feature | `epg_timeshift` | `epg_request_timeshift` |
|-----------------------|-----------------------------|---------------------------------------------|
| Affects | All EPG program times | Client-provided catchup time ranges only |
| Used in | XMLTV/EPG endpoints | XTream Catchup API |
| Global or Per-Request | Global (affects entire EPG) | Per-request (adjusts client-provided times) |
---
#### Supported Time Formats
Both fields support the same time formats:
| Format | Example | Meaning |
|-------------|------------------------------------|-----------------------------------|
| `[-+]hh:mm` | `-2:30`, `+1:00` | Fixed offset in hours and minutes |
| `hh:mm` | `2:00`, `:30` | Positive offset (implies +) |
| `TimeZone` | `Europe/Paris`, `America/New_York` | IANA timezone name |
##### Common Examples
| Format | Value | Description |
|---------------|--------------------|-------------------------|
| Fixed offset | `-2:00` | Minus 2 hours |
| | `+1:30` | Plus 1 hour 30 minutes |
| | `2:00` | Plus 2 hours (positive) |
| | `:30` | Plus 30 minutes |
| | `:3` | Plus 3 minutes |
| Timezone name | `Europe/Paris` | Paris time zone |
| | `America/New_York` | New York time zone |
| | `Asia/Tokyo` | Tokyo time zone |
---
#### Configuration Examples
##### Example 1: User in Paris, No Additional Catchup Offset
**Scenario:** You're in Paris (UTC+2) and your IPTV provider uses UTC. You want all EPG programs shown in Paris time.
Catchup times should not be adjusted.
```yaml
# config.yml
api_proxy:
users:
- username: paris_user
password: ***
epg_timeshift: Europe/Paris # ✅ All EPG in Paris time zone
epg_request_timeshift: None # ✅ No adjustment for catchup
```
---
##### Example 2: User in New York, -1h30 for Everything
**Scenario:** You're in New York (UTC-5 in winter, UTC-4 in summer). Your EPG is in UTC. You want to shift everything by
-1h30.
```yaml
# config.yml
api_proxy:
users:
- username: ny_user
password: ***
epg_timeshift: America/New_York # ✅ EPG in New York time zone
epg_request_timeshift: -1:30 # ✅ Catchup also shifted by -1h30
```
---
##### Example 3: User in Berlin, -2h Only for Catchup
**Scenario:** You're in Berlin (UTC+1). Your EPG is already in Berlin time zone. However, when clients request catchup,
you want to adjust their times by -2 hours.
```yaml
# config.yml
api_proxy:
users:
- username: berlin_user
password: ***
epg_timeshift: Europe/Berlin # ✅ EPG in Berlin time zone
epg_request_timeshift: -2:00 # ✅ Catchup shifted by -2h
```
---
##### Example 4: User in London, +3h Global, No Catchup Shift
**Scenario:** You're in London (UTC+0 or UTC+1 depending on DST). Your EPG is 3 hours behind. You want to catch up on
all EPG times.
```yaml
# config.yml
api_proxy:
users:
- username: london_user
password: ***
epg_timeshift: +3:00 # ✅ All EPG times +3 hours
epg_request_timeshift: None # ✅ Catchup uses client times as-is
```
---
##### Example 5: User in Sydney, Timezone for EPG, Catchup Unchanged
**Scenario:** You're in Sydney (UTC+10/UTC+11). You want EPG in Sydney time zone. Catchup should use exact times as
clients request.
```yaml
# config.yml
api_proxy:
users:
- username: sydney_user
password: ***
epg_timeshift: Australia/Sydney # ✅ EPG in Sydney time zone
epg_request_timeshift: None # ✅ No catchup adjustment
```
---
#### Real-World Scenarios
##### Scenario 1: Watching EPG in Different Time Zone
**Problem:** Your provider's EPG shows programs in UTC time, but you're in Tokyo (UTC+9).
**Solution:** Use `epg_timeshift: Australia/Sydney` or `epg_timeshift: +9:00`.
**Result:** All EPG programs appear in Tokyo local time, making it easy to find what's on TV right now.
---
##### Scenario 2: Requesting Catchup for Specific Times
**Problem:** A client requests catchup from 14:00-16:00 (2pm-4pm), but you want to adjust this for your time zone.
**Setup:** `epg_request_timeshift: -1:00`
**When client requests:** `start=14:00&end=16:00`
**Tuliprox adjusts to:** `start=13:00&end=15:00` (shifted by -1 hour)
**Result:** Provider receives catchup request for 1pm-3pm (your local time).
---
##### Scenario 3: Same User Needs Different Shifts for Different Features
**Problem:** You want EPG in your local time zone, but catchup requests should use a different offset (or no offset at
all).
**Solution:** Configure different values for each field.
**Result:** XMLTV requests use one shift, XTream catchup uses another shift. Full flexibility!
---
#### Common Questions (FAQ)
##### Q: Can I leave both fields empty?
**A:** Yes! Both `epg_timeshift` and `epg_request_timeshift` are optional (`None`). When empty:
* EPG/EPG times remain unchanged
* Catchup times are exactly as requested by client
* No time shifting is applied
---
##### Q: What's the difference between `+2:00` and `Europe/Paris`?
**A:** Both are valid formats, but they work differently:
* `+2:00`: A fixed +2 hour offset. This is always +2 hours, regardless of Daylight Saving Time (DST).
* `Europe/Paris`: Uses the actual Paris time zone, which automatically handles DST (UTC+1 in winter, UTC+2 in summer).
**Recommendation:** Use timezone names (`Europe/Paris`) for locations with DST. Use fixed offsets (`+2:00`) when you
want a constant shift.
---
##### Q: Which field should I use if I don't know?
**A:** Start with `epg_timeshift`. This is the most common field and affects XMLTV/EPG requests, which are used by most
IPTV applications. Only configure `epg_request_timeshift` if you specifically need to adjust catchup time requests.
---
##### Q: Can I use both fields with different values?
**A:** Absolutely! This is the intended design. For example:
* `epg_timeshift: Europe/Berlin` (EPG in Berlin time)
* `epg_request_timeshift: -2:00` (Catchup shifted by -2 hours)
Each field affects only its specific use case, giving you complete control.
---
##### Q: How do negative timeshifts work?
**A:** Negative values shift times **backwards** in time.
**Example:** `epg_timeshift: -2:30`
If EPG shows a program at 20:00, it will appear at 17:30 to your client.
**Common use:** You're ahead of your provider's time zone and need to go back.
---
##### Q: What is the maximum/minimum timeshift I can set?
**A:** There is no hard limit in Tuliprox, but practical limits apply:
* **For fixed offsets:** Typically -12 to +14 hours (covering most global time differences)
* **For timezone names:** Any IANA timezone name (e.g., `Pacific/Honolulu` to `Etc/GMT+14`)
---
##### Q: Will timeshift affect recording/catchup?
**A:** Only `epg_request_timeshift` affects catchup requests. `epg_timeshift` only affects EPG/EPG program times and
does not modify actual stream or recording times.
---
##### Q: Do I need to restart Tuliprox after changing these settings?
**A:** No! Configuration changes are detected automatically and applied without restart. The new timeshift values will
be used for the next request.
---
#### Quick Reference
| I Want To... | Use This Field | Format Example |
|--------------------------------------|-------------------------------|----------------------------------------------------------|
| Shift all EPG times globally | `epg_timeshift` | `Europe/Paris` or `-2:00` |
| Adjust catchup time requests | `epg_request_timeshift` | `America/New_York` or `+1:30` |
| No time shifting | Leave both empty | `None` or omit field |
| Handle DST automatically | Use timezone name | `Europe/london` |
| Constant offset regardless of DST | Use fixed offset | `+1:00` |
| EPG in local time, catchup different | Set different values for both | `epg_timeshift: Berlin` / `epg_request_timeshift: -1:00` |
---
#### Getting Help
If you're unsure which values to use:
1. **Check your local time zone** and compare with your IPTV provider's EPG
2. **Calculate the difference** in hours (e.g., "I'm 2 hours ahead of EPG")
3. **Set `epg_timeshift` to that value** if you want EPG adjusted
4. **Set `epg_request_timeshift`** only if you specifically need to adjust catchup requests
5. **Test with your IPTV app** to verify times appear correctly
For timezone names, see: [IANA Time Zone Database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
---
**Note:** These settings only affect EPG (Electronic Program Guide) times. They do not change when streams are actually
aired - that's controlled by your IPTV provider.
---
&nbsp;
## Additional Information
+2 -1
View File
@@ -1233,7 +1233,8 @@
"XTREAM_BATCH": "xc batch",
"XTREAM_CODES": "xtream codes",
"X_HEADER": "Remove X-* headers",
"YES": "Yes"
"YES": "Yes",
"FORCE": "Force"
},
"MESSAGES": {
"CLIPBOARD_NOT_SUPPORTED": "Clipboard not supported.\nYour browser or current context does not allow clipboard access.\nPlease use HTTPS or localhost.",
+21 -6
View File
@@ -67,7 +67,7 @@
}
:root {
--primary-color: rgba(96, 165, 250, 0.72);
--primary-color: rgba(96, 165, 250, 0.4);
/* Icy Blue */
--secondary-color: rgba(251, 113, 133, 0.62);
/* Soft Coral Red */
@@ -98,6 +98,16 @@
--card-color: var(--modest-text-color);
--sub-card-background-color: rgba(15, 23, 42, 0.62);
--tertiary-background-color: rgba(168, 85, 247, 0.1);
--tertiary-border-color: rgba(168, 85, 247, 0.4);
--tertiary-text-color: rgb(244, 244, 245);
--tertiary-title-text-color: rgb(216, 180, 254);
--quaternary-background-color: rgba(34, 197, 94, 0.2);
--quaternary-border-color: rgba(34, 197, 94, 0.4);
--quaternary-text-color: rgb(244, 244, 245);
--quaternary-title-text-color: rgb(110, 231, 183);
--quaternary-hover-background-color: rgb(34, 197, 94, 0.3);
--text-button-background-color: rgba(30, 64, 175, 0.28);
--text-button-color: #ffffff;
@@ -114,12 +124,17 @@
--text-button-secondary-hover-background-color: rgba(220, 70, 70, 0.55);
--text-button-secondary-hover-color: #ffffff;
--text-button-secondary-border-color: rgba(220, 70, 70, 0.5);
--text-button-tertiary-background-color: var(--tertiary-background-color);
--text-button-tertiary-color: var(--tertiary-title-text-color);
--text-button-tertiary-hover-background-color: rgba(168, 85, 247, 0.2);
--text-button-tertiary-hover-color: rgb(233, 213, 255);
--text-button-tertiary-border-color: var(--tertiary-border-color);
--text-button-active-background-color: rgba(34, 197, 94, 0.58);
--text-button-active-color: #fdfdfd;
--text-button-active-hover-background-color: rgba(22, 163, 74, 0.72);
--text-button-active-hover-color: #ffffff;
--text-button-active-border-color: rgba(74, 222, 128, 0.82);
--text-button-active-background-color: var(--quaternary-hover-background-color);
--text-button-active-color: var(--quaternary-title-text-color);
--text-button-active-hover-background-color: var(--quaternary-background-color);
--text-button-active-hover-color: var(--quaternary-text-color);
--text-button-active-border-color: var(--quaternary-border-color);
--text-button-disabled-border-color: #717171;
--text-button-disabled-background-color: #494949;
@@ -1,16 +1,22 @@
.tp__radio-button-group {
display: flex;
flex-flow: row nowrap;
button {
margin: 0;
}
button:not(:first-child) {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
border-right: 0 !important;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
button:not(:last-child) {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
border-left: 0 !important;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
button:not(:first-child) {
border-left: none;
}
}
+4 -4
View File
@@ -26,12 +26,12 @@
}
}
.tp__text-button {
.tp__text-button:not(.active) {
color: var(--modest-text-color);
}
.tp__icon-button,
.tp__text-button {
.tp__text-button:not(.active) {
&:hover {
color: var(--tab-hover-color);
fill: currentColor;
@@ -40,8 +40,8 @@
}
&--active {
.tp__icon-button,
.tp__text-button {
.tp__icon-button:not(.active),
.tp__text-button:not(.active) {
background-color: var(--tab-active-background-color);
color: var(--tab-active-color);
fill: currentColor;
+21 -3
View File
@@ -33,7 +33,7 @@
}
}
.tp__text-button.primary {
.tp__text-button.primary:not(.active) {
background-color: var(--text-button-primary-background-color);
border-color: var(--text-button-primary-border-color);
color: var(--text-button-primary-color);
@@ -47,7 +47,7 @@
}
}
.tp__text-button.secondary {
.tp__text-button.secondary:not(.active) {
background-color: var(--text-button-secondary-background-color);
border-color: var(--text-button-secondary-border-color);
color: var(--text-button-secondary-color);
@@ -61,7 +61,24 @@
}
}
.tp__text-button.active {
.tp__text-button.tertiary:not(.active) {
background-color: var(--text-button-tertiary-background-color);
border-color: var(--text-button-tertiary-border-color);
color: var(--text-button-tertiary-color);
fill: var(--text-button-tertiary-color);
&:focus,
&:hover {
background-color: var(--text-button-tertiary-hover-background-color);
color: var(--text-button-tertiary-hover-color);
fill: var(--text-button-tertiary-hover-color);
outline: none;
}
}
.tp__text-button.active,
.tp__text-button.primary.active,
.tp__text-button.secondary.active,
.tp__text-button.tertiary.active {
background-color: var(--text-button-active-background-color);
border-color: var(--text-button-active-border-color);
color: var(--text-button-active-color);
@@ -69,6 +86,7 @@
&:focus,
&:hover {
background-color: var(--text-button-active-hover-background-color);
border-color: var(--text-button-active-border-color);
color: var(--text-button-active-hover-color);
fill: var(--text-button-active-hover-color);
outline: none;
@@ -34,6 +34,20 @@
}
}
}
&__system {
background-color: var(--tertiary-background-color);
border-color: var(--tertiary-border-color);
.tp__status-card__body {
color: var(--tertiary-text-color);
}
.tp__status-card__title {
font-weight: bold;
color: var(--tertiary-title-text-color);
}
}
}
.tp__collapse-panel.tp__expanded {
+2 -2
View File
@@ -4,7 +4,7 @@ use yew::prelude::*;
#[derive(Properties, Clone, PartialEq, Debug)]
pub struct CardProps {
#[prop_or_default]
pub class: String,
pub class: Classes,
pub children: Children,
}
@@ -14,7 +14,7 @@ pub fn Card(props: &CardProps) -> Html {
let context = CardContext { custom_class: custom_class.clone() };
html! {
<ContextProvider<CardContext> context={context}>
<div class={classes!("tp__card", &props.class, &*custom_class)}>
<div class={classes!("tp__card", props.class.clone(), &*custom_class)}>
{ for props.children.iter() }
</div>
</ContextProvider<CardContext>>
@@ -30,6 +30,16 @@ pub fn StatsView(props: &StatsViewProps) -> Html {
},
);
let render_system_stats = |cache| {
html! {
<div class="tp__stats__body-group">
<Card class="tp__stats__system"><StatusCard title={translate.t("LABEL.MEMORY")} data={mem.clone()} /></Card>
<Card class="tp__stats__system"><StatusCard title={translate.t("LABEL.CPU")} data={cpu.clone()} /></Card>
<Card class="tp__stats__system"><StatusCard title={translate.t("LABEL.CACHE")} data={cache} /></Card>
</div>
}
};
let render_streams_embedded = || {
let cache = status_ctx.status.as_ref().map_or_else(
|| "n/a".to_string(),
@@ -44,11 +54,7 @@ pub fn StatsView(props: &StatsViewProps) -> Html {
</div>
})}>
<div class="tp__stats__body">
<div class="tp__stats__body-group">
<Card><StatusCard title={translate.t("LABEL.MEMORY")} data={mem.clone()} /></Card>
<Card><StatusCard title={translate.t("LABEL.CPU")} data={cpu.clone()} /></Card>
<Card><StatusCard title={translate.t("LABEL.CACHE")} data={cache} /></Card>
</div>
{ render_system_stats(cache) }
<div class="tp__stats__body-group">
<Card><PlaylistProgressStatusCard /></Card>
</div>
@@ -128,11 +134,7 @@ pub fn StatsView(props: &StatsViewProps) -> Html {
<h1>{ translate.t("LABEL.STATS")}</h1>
</div>
<div class="tp__stats__body">
<div class="tp__stats__body-group">
<Card><StatusCard title={translate.t("LABEL.MEMORY")} data={mem.clone()} /></Card>
<Card><StatusCard title={translate.t("LABEL.CPU")} data={cpu.clone()} /></Card>
<Card><StatusCard title={translate.t("LABEL.CACHE")} data={cache} /></Card>
</div>
{ render_system_stats(cache) }
<div class="tp__stats__body-group">
<Card><PlaylistProgressStatusCard /></Card>
</div>
@@ -13,7 +13,9 @@ use yew::{platform::spawn_local, prelude::*};
use yew_hooks::use_list;
const LABEL_UPDATE_LOCAL_LIBRARY: &str = "LABEL.UPDATE_LOCAL_LIBRARY";
const LABEL_FORCE: &str = "LABEL.FORCE";
const ACTION_UPDATE_LIBRARY: &str = "update_library";
const ACTION_UPDATE_LIBRARY_FORCE: &str = "update_library_force";
#[component]
pub fn PlaylistUpdateView() -> Html {
@@ -84,8 +86,13 @@ pub fn PlaylistUpdateView() -> Html {
let services = services.clone();
let translate = translate.clone();
wasm_bindgen_futures::spawn_local(async move {
if name.as_str() == ACTION_UPDATE_LIBRARY {
match services.config.update_library().await {
let mode = match name.as_str() {
ACTION_UPDATE_LIBRARY => 1,
ACTION_UPDATE_LIBRARY_FORCE => 2,
_ => 0,
};
if mode > 0 {
match services.config.update_library(mode == 2).await {
Ok(_) => services.toastr.success(translate.t("MESSAGES.LIBRARY_UPDATE.SUCCESS")),
Err(_err) => services.toastr.error(translate.t("MESSAGES.LIBRARY_UPDATE.FAIL")),
}
@@ -103,10 +110,15 @@ pub fn PlaylistUpdateView() -> Html {
<h1>{ translate.t("LABEL.UPDATE")}</h1>
<div class="tp__config-view__header-tools">
{html_if!(can_write_library && library_enabled, {
<div class="tp__radio-button-group">
<TextButton class="tertiary" name={ACTION_UPDATE_LIBRARY}
icon="Refresh"
title={ translate.t(LABEL_UPDATE_LOCAL_LIBRARY)}
onclick={handle_update_content.clone()}></TextButton>
<TextButton class="tertiary" name={ACTION_UPDATE_LIBRARY_FORCE}
title={ translate.t(LABEL_FORCE)}
onclick={handle_update_content.clone()}></TextButton>
</div>
})}
</div>
{ html_if!(can_write_playlist, {
+2 -2
View File
@@ -433,9 +433,9 @@ impl ConfigService {
request_get::<()>(&self.geoip_path, None, None).await
}
pub async fn update_library(&self) -> Result<Option<()>, Error> {
pub async fn update_library(&self, force_rescan: bool) -> Result<Option<()>, Error> {
let path = concat_path(&self.library_path, "scan");
let params = LibraryScanRequest { force_rescan: false };
let params = LibraryScanRequest { force_rescan };
request_post::<LibraryScanRequest, ()>(&path, params, None, None).await
}
+27
View File
@@ -254,6 +254,33 @@ pub struct XtreamSeriesInfo {
pub episodes: Option<Vec<XtreamSeriesInfoEpisode>>,
}
#[cfg(test)]
mod tests {
use super::XtreamVideoInfo;
use serde_json::json;
#[test]
fn xtream_video_info_accepts_numeric_tmdb_id() {
let parsed: XtreamVideoInfo = serde_json::from_value(json!({
"info": {
"tmdb_id": 1285728,
"name": "A Normal Woman"
},
"movie_data": {
"stream_id": 982447,
"name": "A Normal Woman - 2025",
"added": "1771006438",
"category_id": "727",
"container_extension": "mkv",
"direct_source": ""
}
}))
.expect("numeric tmdb_id should deserialize");
assert_eq!(parsed.info.tmdb_id.as_ref(), "1285728");
}
}
// sometimes episodes are a map with season as key, sometimes an array
fn deserialize_episodes<'de, D>(deserializer: D) -> Result<Option<Vec<XtreamSeriesInfoEpisode>>, D::Error>
where
+116 -27
View File
@@ -134,30 +134,43 @@ pub fn interner_gc() -> usize {
0
}
/// Convert an `f64` that reached `visit_f64` into a round-trip-safe string
/// using the canonical YAML 1.2 spelling (`.inf`, `-.inf`, `.nan`).
/// Convert an `f64` that reached `visit_f64` into a round-trip-safe string.
///
/// `serde_saphyr` recognises these spellings as ambiguous and **quotes** them
/// when re-serializing, so the value survives a YAML round-trip as a string.
/// Special values are emitted as `"infinity"`, `"-infinity"`, and `"nan"`.
/// `serde_saphyr` re-serializes these ambiguous scalars quoted, so they
/// survive a YAML round-trip as strings.
///
/// This is a safety-net: the primary fix is using `deserialize_string` (which
/// skips float parsing entirely), so `visit_f64` is normally not reached for
/// plain string fields.
/// This is a safety-net for paths that intentionally accept typed numeric
/// scalars and normalize them into strings.
#[inline]
fn f64_to_str(v: f64) -> String {
if v.is_infinite() {
if v.is_sign_positive() {
".inf".to_owned()
"infinity".to_owned()
} else {
"-.inf".to_owned()
"-infinity".to_owned()
}
} else if v.is_nan() {
".nan".to_owned()
"nan".to_owned()
} else {
v.to_string()
}
}
#[inline]
// This intentionally only normalizes the lowercase dot-prefixed spellings that
// serde_saphyr emits for special float scalars. Other YAML 1.1 variants such as
// `.Inf`, `.INF`, or `.NaN` are out of scope here because they are not produced
// by the current parser path.
fn normalize_scalar_string(value: &str) -> &str {
match value {
".inf" => "infinity",
"-.inf" => "-infinity",
".nan" => "nan",
_ => value,
}
}
//
// Two reusable visitor types live here so that multiple public entry-points
// can share them without code duplication:
@@ -165,9 +178,9 @@ fn f64_to_str(v: f64) -> String {
// ArcStrVisitor -> Arc<str> (null/empty -> "")
// OptionArcStrVisitor -> Option<Arc<str>> (null/empty -> None)
//
// `ArcStrVisitor::visit_some` uses `deserialize_string`, which tells saphyr to
// return the **raw scalar text** without float-parsing. That is what makes
// `name: infinity` survive as the literal string `"infinity"`.
// `ArcStrVisitor::visit_some` uses `deserialize_any(self)`, so numeric/bool
// scalars can flow into the typed `visit_*` methods. The tradeoff is that raw
// numeric notation may be normalized (for example `1e2` becomes `"100"`).
//
// `OptionArcStrVisitor::visit_some` intentionally diverges and uses
// `deserialize_any` so JSON/YAML numeric inputs can flow into `visit_i64`,
@@ -182,18 +195,19 @@ impl<'de> Visitor<'de> for ArcStrVisitor {
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str("a string, number, boolean, or null") }
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> { Ok(v.intern()) }
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> { Ok(v.intern()) }
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
Ok(normalize_scalar_string(v).intern())
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> {
Ok(normalize_scalar_string(v.as_str()).intern())
}
fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<Self::Value, E> { Ok(v.to_string().intern()) }
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> { Ok(v.to_string().intern()) }
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> { Ok(v.to_string().intern()) }
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Self::Value, E> { Ok(f64_to_str(v).intern()) }
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> { Ok("".intern()) }
fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> { Ok("".intern()) }
fn visit_some<D: Deserializer<'de>>(self, d: D) -> Result<Self::Value, D::Error> {
// `deserialize_string` returns the raw text -> `infinity` stays `infinity`.
d.deserialize_string(self)
}
fn visit_some<D: Deserializer<'de>>(self, d: D) -> Result<Self::Value, D::Error> { d.deserialize_any(self) }
}
/// Visitor that produces `Option<Arc<str>>`, mapping null / empty -> `None`.
@@ -206,8 +220,22 @@ impl<'de> Visitor<'de> for OptionArcStrVisitor {
f.write_str("a string, number, boolean, null, or empty")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> { Ok(Some(v.intern())) }
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> { Ok(Some(v.intern())) }
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
let normalized = normalize_scalar_string(v);
if normalized.is_empty() {
Ok(None)
} else {
Ok(Some(normalized.intern()))
}
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> {
let normalized = normalize_scalar_string(v.as_str());
if normalized.is_empty() {
Ok(None)
} else {
Ok(Some(normalized.intern()))
}
}
fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<Self::Value, E> { Ok(Some(v.to_string().intern())) }
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> { Ok(Some(v.to_string().intern())) }
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> { Ok(Some(v.to_string().intern())) }
@@ -217,10 +245,12 @@ impl<'de> Visitor<'de> for OptionArcStrVisitor {
fn visit_some<D: Deserializer<'de>>(self, d: D) -> Result<Self::Value, D::Error> { d.deserialize_any(self) }
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
while seq.next_element::<IgnoredAny>()?.is_some() {}
log::debug!("ignored sequence while deserializing string interner, returning None");
Ok(None)
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
while map.next_entry::<IgnoredAny, IgnoredAny>()?.is_some() {}
log::debug!("ignored map while deserializing string interner, returning None");
Ok(None)
}
}
@@ -259,12 +289,12 @@ pub mod arc_str_serde {
serializer.serialize_str(value)
}
/// Deserialize a YAML scalar as an interned `Arc<str>`.
/// Deserialize a scalar as an interned `Arc<str>`.
///
/// Uses `deserialize_string` so saphyr hands us the **raw text** without
/// first running it through float/bool/int parsing. This preserves values
/// like `infinity` as the literal string `"infinity"` instead of silently
/// converting them to `".inf"`.
/// This goes through `deserialize_option(ArcStrVisitor)`, and
/// `ArcStrVisitor::visit_some` uses `deserialize_any`. That allows numeric
/// JSON/YAML scalars to be accepted, but raw numeric notation may be lost
/// during normalization (for example `1e2` becomes `"100"`).
pub fn deserialize<'de, D>(deserializer: D) -> Result<Arc<str>, D::Error>
where
D: Deserializer<'de>,
@@ -312,7 +342,8 @@ pub mod arc_str_option_null_if_empty_serde {
//
// Reuses `ArcStrVisitor` / `OptionArcStrVisitor` via `deserialize_option`:
// - null / ~ / empty -> visit_none / visit_unit -> "" / None
// - `ArcStrVisitor::visit_some` -> deserialize_string -> raw scalar text
// - `ArcStrVisitor::visit_some` -> deserialize_any -> numbers/bools map into
// typed `visit_*` methods, so raw numeric notation may be normalized
// - `OptionArcStrVisitor::visit_some` -> deserialize_any -> numbers/bools map
// into their typed `visit_*` methods before being interned as strings
@@ -336,12 +367,36 @@ where
mod tests {
use super::*;
#[derive(Debug, serde::Deserialize)]
struct ArcStrHolder {
#[serde(default, with = "arc_str_serde")]
value: Arc<str>,
}
#[derive(Debug, serde::Deserialize)]
struct OptArcStrHolder {
#[serde(default, with = "arc_str_option_serde")]
value: Option<Arc<str>>,
}
#[test]
fn arc_str_serde_preserves_yaml_infinity_literal_as_string() {
let parsed: ArcStrHolder = serde_saphyr::from_str("value: infinity\n").unwrap();
assert_eq!(parsed.value.as_ref(), "infinity");
}
#[test]
fn arc_str_serde_preserves_yaml_numeric_like_word_as_string() {
let parsed: ArcStrHolder = serde_saphyr::from_str("value: 01abc\n").unwrap();
assert_eq!(parsed.value.as_ref(), "01abc");
}
#[test]
fn arc_str_serde_accepts_json_integer() {
let parsed: ArcStrHolder = serde_json::from_str(r#"{"value":1285728}"#).unwrap();
assert_eq!(parsed.value.as_ref(), "1285728");
}
#[test]
fn arc_str_option_serde_accepts_json_integer() {
let parsed: OptArcStrHolder = serde_json::from_str(r#"{"value":8169}"#).unwrap();
@@ -353,4 +408,38 @@ mod tests {
let parsed: OptArcStrHolder = serde_json::from_str(r#"{"value":"8169"}"#).unwrap();
assert_eq!(parsed.value.as_deref(), Some("8169"));
}
#[test]
fn arc_str_option_serde_maps_empty_string_to_none() {
let parsed: OptArcStrHolder = serde_json::from_str(r#"{"value":""}"#).unwrap();
assert_eq!(parsed.value, None);
}
#[test]
fn arc_str_serde_normalizes_json_scientific_notation_numbers() {
let parsed: ArcStrHolder = serde_json::from_str(r#"{"value":1e2}"#).unwrap();
assert_eq!(parsed.value.as_ref(), "100");
}
#[test]
fn arc_str_serde_normalizes_yaml_special_float_scalars() {
let parsed_inf: ArcStrHolder = serde_saphyr::from_str("value: .inf\n").unwrap();
let parsed_neg_inf: ArcStrHolder = serde_saphyr::from_str("value: -.inf\n").unwrap();
let parsed_nan: ArcStrHolder = serde_saphyr::from_str("value: .nan\n").unwrap();
assert_eq!(parsed_inf.value.as_ref(), "infinity");
assert_eq!(parsed_neg_inf.value.as_ref(), "-infinity");
assert_eq!(parsed_nan.value.as_ref(), "nan");
}
#[test]
fn arc_str_option_serde_normalizes_yaml_special_float_scalars() {
let parsed_inf: OptArcStrHolder = serde_saphyr::from_str("value: .inf\n").unwrap();
let parsed_neg_inf: OptArcStrHolder = serde_saphyr::from_str("value: -.inf\n").unwrap();
let parsed_nan: OptArcStrHolder = serde_saphyr::from_str("value: .nan\n").unwrap();
assert_eq!(parsed_inf.value.as_deref(), Some("infinity"));
assert_eq!(parsed_neg_inf.value.as_deref(), Some("-infinity"));
assert_eq!(parsed_nan.value.as_deref(), Some("nan"));
}
}