use crate::api::api_utils::resource_response; use crate::{api::{ api_utils::{create_api_proxy_user, json_or_bin_response, try_option_bad_request, try_result_bad_request, try_unwrap_body}, endpoints::{ api_playlist_utils::{get_playlist_for_custom_provider, get_playlist_for_input, get_playlist_for_target}, extract_accept_header::ExtractAcceptHeader, m3u_api::m3u_api_stream_loaded, xmltv_api::{rewrite_epg_channel_resource_url, serve_epg_web_ui, stream_epg_api}, xtream_api::{xtream_get_stream_info_response, xtream_player_api_stream_with_token, ApiStreamContext, ApiStreamRequest}, }, model::AppState, }, auth::{create_access_token, permission_layer, verify_access_token}, model::{parse_xmltv_for_web_ui_from_file, parse_xmltv_for_web_ui_from_url, AppConfig, ConfigInput, ConfigInputFlags, ConfigInputOptions, InputSource}, processing::parser::xmltv::merge_epg_channels_by_priority, repository::{m3u_get_item_for_stream_id, xtream_get_item_for_stream_id}, utils::{epg::get_input_raw_epg_file_path, file_exists_async, xtream}}; use axum::{response::IntoResponse, Router}; use log::{debug, error}; use serde_json::json; use shared::utils::deobfuscate_text; use shared::{ model::{ permission::Permission, EpgChannel, OperationRunAccepted, InputType, PlaylistEpgRequest, PlaylistRequest, PlaylistUrlResolveRequest, ProxyType, TargetType, UiPlaylistItem, XtreamCluster, }, utils::{concat_path_leading_slash, sanitize_sensitive_info, Internable}, }; use std::{str::FromStr, sync::Arc}; use url::Url; fn create_config_input_for_m3u(url: &str) -> ConfigInput { ConfigInput { id: 0, name: "m3u_req".intern(), input_type: InputType::M3u, url: String::from(url), enabled: true, options: Some(ConfigInputOptions { flags: ConfigInputFlags::XtreamLiveStreamUsePrefix | ConfigInputFlags::ResolveBackground, resolve_delay: shared::utils::default_resolve_delay_secs(), probe_delay: shared::utils::default_probe_delay_secs(), probe_live_interval_hours: 120, resolve_filter: None, probe_filter: None, }), ..Default::default() } } fn create_config_input_for_xtream(username: &str, password: &str, host: &str) -> ConfigInput { ConfigInput { id: 0, name: "xc_req".intern(), input_type: InputType::Xtream, url: String::from(host), username: Some(String::from(username)), password: Some(String::from(password)), enabled: true, options: Some(ConfigInputOptions { flags: ConfigInputFlags::XtreamLiveStreamUsePrefix | ConfigInputFlags::ResolveBackground, resolve_delay: shared::utils::default_resolve_delay_secs(), probe_delay: shared::utils::default_probe_delay_secs(), probe_live_interval_hours: 120, resolve_filter: None, probe_filter: None, }), ..Default::default() } } fn resolve_provider_url_with_input(input: &ConfigInput, url: &str) -> String { match input.resolve_url(url) { Ok(resolved) => resolved.into_owned(), Err(err) => { let sanitized_url = sanitize_sensitive_info(url); let err_text = err.to_string(); let sanitized_err = sanitize_sensitive_info(&err_text); error!("resolve_provider_url_with_input failed for url '{sanitized_url}': {sanitized_err}"); url.to_string() } } } fn resolve_provider_url_for_request(app_config: &AppConfig, playlist_request: &PlaylistRequest, url: &str) -> String { if !url.starts_with(shared::utils::PROVIDER_SCHEME_PREFIX) { return url.to_string(); } match playlist_request { PlaylistRequest::Input(input_name) => app_config .get_input_by_name(&input_name.intern()) .map_or_else(|| url.to_string(), |input| resolve_provider_url_with_input(input.as_ref(), url)), PlaylistRequest::Target(target_id) => app_config .get_target_by_id(*target_id) .and_then(|target| app_config.get_inputs_for_target(&target.name)) .and_then(|inputs| { let mut matches = inputs .into_iter() .filter(|input| input.get_resolve_provider(url).is_some()); let first = matches.next()?; if matches.next().is_some() { return None; } Some(resolve_provider_url_with_input(first.as_ref(), url)) }) .unwrap_or_else(|| url.to_string()), PlaylistRequest::CustomXtream(_) | PlaylistRequest::CustomM3u(_) => url.to_string(), } } fn build_playlist_webplayer_url( base_url: &str, access_token: &str, target_id: u16, virtual_id: u32, cluster: XtreamCluster, ) -> String { format!( "{base_url}/api/v1/playlist/webplayer/{access_token}/{}/{}/{}", target_id, cluster.as_stream_type(), virtual_id ) } fn merge_epg_channels(mut channels_by_source: Vec<(i16, Vec)>) -> Vec { channels_by_source.sort_by_key(|(priority, _)| *priority); merge_epg_channels_by_priority(channels_by_source) } async fn load_epg_channels_for_input( app_state: &Arc, input: &ConfigInput, ) -> Result>, shared::error::TuliproxError> { let Some(epg_config) = input.epg.as_ref() else { return Ok(None); }; let storage_dir = app_state.app_config.config.load().storage_dir.clone(); let mut channels_by_source = Vec::new(); let mut failed_sources = 0usize; for epg_source in &epg_config.sources { let resolved_url = resolve_provider_url_with_input(input, &epg_source.url); let raw_epg_path = match get_input_raw_epg_file_path(&resolved_url, input, &storage_dir).await { Ok(path) => path, Err(err) => { debug!( "Skipping EPG source {}: {}", sanitize_sensitive_info(resolved_url.as_str()), sanitize_sensitive_info(&err.to_string()) ); failed_sources += 1; continue; } }; let source_channels = if file_exists_async(&raw_epg_path).await { match parse_xmltv_for_web_ui_from_file(&raw_epg_path).await { Ok(ch) => ch, Err(file_err) => { debug!( "EPG file parse failed {}, trying upstream: {file_err}", sanitize_sensitive_info(raw_epg_path.to_str().unwrap_or_default()) ); match parse_xmltv_for_web_ui_from_url(app_state, &resolved_url).await { Ok(ch) => ch, Err(url_err) => { debug!("EPG url also failed {}: {}", sanitize_sensitive_info(resolved_url.as_str()), sanitize_sensitive_info(&url_err.to_string())); failed_sources += 1; continue; } } } } } else { match parse_xmltv_for_web_ui_from_url(app_state, &resolved_url).await { Ok(ch) => ch, Err(err) => { debug!("Skipping EPG url {}: {}", sanitize_sensitive_info(resolved_url.as_str()), sanitize_sensitive_info(&err.to_string())); failed_sources += 1; continue; } } }; channels_by_source.push((epg_source.priority, source_channels)); } if channels_by_source.is_empty() { if failed_sources > 0 { Err(shared::error::TuliproxError::Config(format!( "All {failed_sources} EPG source(s) failed for input '{}'", input.name ))) } else { Ok(None) } } else { Ok(Some(merge_epg_channels(channels_by_source))) } } async fn playlist_update( axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(targets): axum::extract::Json>, ) -> impl axum::response::IntoResponse + Send { let user_targets = if targets.is_empty() { None } else { Some(targets) }; let process_targets = app_state.app_config.sources.load().validate_targets(user_targets.as_ref()); match process_targets { Ok(valid_targets) => { let valid_targets = Arc::new(valid_targets); // Deduplicate rapid clicks: the channel has capacity 1, so at most one // update is queued at any time. Additional requests while the channel // is full are silently dropped — the pending run already covers them. match app_state .manual_update_sender .try_send(crate::api::model::ManualPlaylistUpdateRequest { targets: valid_targets }) { Ok(()) => (axum::http::StatusCode::ACCEPTED, axum::Json(OperationRunAccepted {})).into_response(), Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { debug!("Manual playlist update deduplicated: an update is already pending or running"); (axum::http::StatusCode::ACCEPTED, axum::Json(OperationRunAccepted {})).into_response() } Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { debug!("Manual playlist update rejected: worker channel closed (server shutting down)"); axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response() } } } Err(err) => { error!("Failed playlist update {}", sanitize_sensitive_info(&err.to_string())); (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response() } } } async fn playlist_content( accept: Option, app_state: &Arc, playlist_req: &PlaylistRequest, cluster: XtreamCluster, ) -> impl IntoResponse + Send { let client = app_state.http_client.load(); match playlist_req { PlaylistRequest::Target(target_id) => get_playlist_for_target( app_state.app_config.get_target_by_id(*target_id).as_deref(), app_state, cluster, accept.as_deref(), ) .await .into_response(), PlaylistRequest::Input(input_name) => get_playlist_for_input( app_state.app_config.get_input_by_name(&input_name.intern()).as_ref(), app_state, cluster, accept.as_deref(), ) .await .into_response(), PlaylistRequest::CustomXtream(xtream) => match Url::parse(&xtream.url) { Ok(parsed) if parsed.scheme() == "http" || parsed.scheme() == "https" => { let input = Arc::new(create_config_input_for_xtream(&xtream.username, &xtream.password, &xtream.url)); get_playlist_for_custom_provider( client.as_ref(), Some(&input), app_state, cluster, accept.as_deref(), ) .await .into_response() } _ => ( axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url scheme; only http/https are allowed"})), ) .into_response(), }, PlaylistRequest::CustomM3u(m3u) => match Url::parse(&m3u.url) { Ok(parsed) if parsed.scheme() == "http" || parsed.scheme() == "https" => { let input = Arc::new(create_config_input_for_m3u(&m3u.url)); get_playlist_for_custom_provider( client.as_ref(), Some(&input), app_state, cluster, accept.as_deref(), ) .await .into_response() } _ => ( axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url scheme; only http/https are allowed"})), ) .into_response(), }, } } macro_rules! create_player_api_for_cluster { ($fn_name:ident, $cluster:expr) => { async fn $fn_name( ExtractAcceptHeader(accept): ExtractAcceptHeader, axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(playlist_req): axum::extract::Json, ) -> impl IntoResponse + Send { playlist_content(accept.clone(), &app_state, &playlist_req, $cluster).await.into_response() } }; } create_player_api_for_cluster!(playlist_content_live, XtreamCluster::Live); create_player_api_for_cluster!(playlist_content_vod, XtreamCluster::Video); create_player_api_for_cluster!(playlist_content_series, XtreamCluster::Series); async fn playlist_series_info( axum::extract::Path((virtual_id, provider_id)): axum::extract::Path<(String, String)>, axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(playlist_req): axum::extract::Json, ) -> impl IntoResponse + Send { let provider_id = provider_id.trim().parse::().ok(); match playlist_req { PlaylistRequest::Target(target_id) => { if let Some(target) = app_state.app_config.get_target_by_id(target_id) { if target.has_output(TargetType::Xtream) { let mut user = create_api_proxy_user(&app_state); user.proxy = ProxyType::Redirect; return xtream_get_stream_info_response( &app_state, &user, &target, &virtual_id, XtreamCluster::Series, ) .await .into_response(); } } } PlaylistRequest::Input(input_name) => { if let Some(input) = app_state.app_config.get_input_by_name(&input_name.intern()) { if input.input_type.is_xtream() { // We cannot call `xtream_get_stream_info_response` directly here because that path // depends on target-local virtual-id mapping (`xtream_get_item_for_stream_id`). // Input/custom requests only provide provider_id, so we resolve series info from // the upstream Xtream API using provider_id. if let Some(provider_id) = provider_id { if let Some(info_url) = xtream::get_xtream_player_api_info_url( input.as_ref(), XtreamCluster::Series, provider_id, ) { let Ok(resolved_url) = input.resolve_url(&info_url) else { return axum::http::StatusCode::NO_CONTENT.into_response(); }; let input_source = InputSource::from(input.as_ref()).with_url(resolved_url.to_string()); if let Ok(content) = xtream::get_xtream_stream_info_content( &app_state.app_config, &app_state.http_client.load(), &input_source, false, ) .await { return try_unwrap_body!(axum::response::Response::builder() .status(axum::http::StatusCode::OK) .header(axum::http::header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()) .body(axum::body::Body::from(content))); } } } } } } PlaylistRequest::CustomXtream(xtream_req) => { if let Some(provider_id) = provider_id { let input = create_config_input_for_xtream(&xtream_req.username, &xtream_req.password, &xtream_req.url); if let Some(info_url) = xtream::get_xtream_player_api_info_url(&input, XtreamCluster::Series, provider_id) { let input_source = InputSource::from(&input).with_url(info_url); if let Ok(content) = xtream::get_xtream_stream_info_content( &app_state.app_config, &app_state.http_client.load(), &input_source, false, ) .await { return try_unwrap_body!(axum::response::Response::builder() .status(axum::http::StatusCode::OK) .header(axum::http::header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()) .body(axum::body::Body::from(content))); } } } } PlaylistRequest::CustomM3u(_) => {} } axum::http::StatusCode::NO_CONTENT.into_response() } fn playlist_webplayer( axum::extract::State(app_state): axum::extract::State>, target_id: u16, virtual_id: u32, cluster: XtreamCluster, ) -> impl axum::response::IntoResponse + Send { let access_token = create_access_token(&app_state.app_config.access_token_secret, 30); let config = app_state.app_config.config.load(); let server_name = config .web_ui .as_ref() .and_then(|web_ui| web_ui.player_server.as_ref()) .map_or("default", |server_name| server_name.as_str()); let server_info = app_state.app_config.get_server_info(server_name); let Some(server_info) = server_info else { return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(); }; let base_url = server_info.get_base_url(); build_playlist_webplayer_url(&base_url, &access_token, target_id, virtual_id, cluster).into_response() } async fn playlist_webplayer_stream( fingerprint: crate::auth::Fingerprint, axum::extract::Path((token, target_id, cluster, stream_id)): axum::extract::Path<(String, u16, String, String)>, axum::extract::State(app_state): axum::extract::State>, req_headers: axum::http::HeaderMap, ) -> impl IntoResponse + Send { if !verify_access_token(&token, &app_state.app_config.access_token_secret) { return axum::http::StatusCode::FORBIDDEN.into_response(); } let ctxt = try_result_bad_request!(ApiStreamContext::from_str(cluster.as_str())); let Some(target) = app_state.app_config.get_target_by_id(target_id) else { return axum::http::StatusCode::BAD_REQUEST.into_response(); }; if target.has_output(TargetType::Xtream) { return xtream_player_api_stream_with_token( &fingerprint, &req_headers, &app_state, target_id, ApiStreamRequest::from_access_token(ctxt, &token, &stream_id, ""), ) .await .into_response(); } if !target.has_output(TargetType::M3u) { return axum::http::StatusCode::BAD_REQUEST.into_response(); } let req_virtual_id: u32 = try_result_bad_request!(stream_id.trim().parse()); let pli = try_result_bad_request!( m3u_get_item_for_stream_id(req_virtual_id, &app_state, &target).await, true, format!("Failed to read m3u item for stream id {req_virtual_id}") ); let input = try_option_bad_request!( app_state.app_config.get_input_by_name(&pli.input_name), true, format!("Can't find input {} for target {}", pli.input_name, target.name) ); let user = Arc::new(create_api_proxy_user(&app_state)); m3u_api_stream_loaded(user, target, &fingerprint, &req_headers, &app_state, pli, input, None, None) .await .into_response() } async fn playlist_epg( ExtractAcceptHeader(accept): ExtractAcceptHeader, axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(playlist_epg_req): axum::extract::Json, ) -> impl IntoResponse + Send { match playlist_epg_req { PlaylistEpgRequest::Target(target_id) => { if let Some(target) = app_state.app_config.get_target_by_id(target_id) { let config = &app_state.app_config.config.load(); let epg_path = crate::api::endpoints::xmltv_api::get_epg_path_for_target_by_type( config, &target, TargetType::Xtream, ) .or_else(|| { crate::api::endpoints::xmltv_api::get_epg_path_for_target_by_type( config, &target, TargetType::M3u, ) }); if let Some(epg_path) = epg_path { return serve_epg_web_ui(&app_state, accept.as_deref(), &epg_path, &target).await; } } } PlaylistEpgRequest::Input(input_name) => { if let Some(input) = app_state.app_config.get_input_by_name(&input_name.intern()) { match load_epg_channels_for_input(&app_state, input.as_ref()).await { Ok(Some(epg)) => { let config = app_state.app_config.config.load(); let web_ui_path = config.web_ui.as_ref().and_then(|w| w.path.as_ref()).map_or("", String::as_str); let resource_url = concat_path_leading_slash(web_ui_path, "api/v1/playlist/resource"); let encrypt_secret = app_state.get_encrypt_secret(); let epg = epg .into_iter() .map(|channel| rewrite_epg_channel_resource_url(&encrypt_secret, &resource_url, channel)) .collect::>(); return json_or_bin_response(accept.as_deref(), &epg).into_response(); } Ok(None) => return axum::http::StatusCode::NO_CONTENT.into_response(), Err(err) => { error!("Failed to load input EPG for '{}': {}", input.name, sanitize_sensitive_info(&err.to_string())); return ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(serde_json::json!({"error": "Failed to load EPG"})), ) .into_response(); } } } } PlaylistEpgRequest::Custom(url) => { let valid_custom_url = Url::parse(&url).is_ok_and(|parsed| matches!(parsed.scheme(), "http" | "https")); if !valid_custom_url { return ( axum::http::StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({"error": "Invalid EPG URL"})), ) .into_response(); } match parse_xmltv_for_web_ui_from_url(&app_state, &url).await { Ok(epg) => { let config = app_state.app_config.config.load(); let web_ui_path = config.web_ui.as_ref().and_then(|w| w.path.as_ref()).map_or("", String::as_str); let resource_url = concat_path_leading_slash(web_ui_path, "api/v1/playlist/resource"); let encrypt_secret = app_state.get_encrypt_secret(); let epg = epg .into_iter() .map(|channel| rewrite_epg_channel_resource_url(&encrypt_secret, &resource_url, channel)) .collect::>(); return json_or_bin_response(accept.as_deref(), &epg).into_response(); } Err(err) => { error!("Failed to load custom EPG: {}", sanitize_sensitive_info(&err.to_string())); return ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(serde_json::json!({"error": "Failed to load EPG"})), ) .into_response(); } } } } axum::http::StatusCode::NO_CONTENT.into_response() } async fn playlist_resource( req_headers: axum::http::HeaderMap, axum::extract::Path(resource): axum::extract::Path, axum::extract::State(app_state): axum::extract::State>, ) -> impl IntoResponse + Send { let encrypt_secret = app_state.get_encrypt_secret(); if let Ok(resource_url) = deobfuscate_text(&encrypt_secret, &resource) { resource_response(&app_state, &resource_url, &req_headers, None).await.into_response() } else { axum::http::StatusCode::BAD_REQUEST.into_response() } } async fn playlist_resolve_url( axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(request): axum::extract::Json, ) -> impl IntoResponse + Send { match request { PlaylistUrlResolveRequest::Webplayer { target_id, virtual_id, cluster } => { playlist_webplayer( axum::extract::State(app_state), target_id, virtual_id, cluster, ) .into_response() } PlaylistUrlResolveRequest::Provider { playlist_request, url } => { resolve_provider_url_for_request(&app_state.app_config, &playlist_request, &url).into_response() } } } pub fn v1_api_playlist_register_protected(router: Router>) -> axum::Router> { router .route("/playlist/resolve_url", axum::routing::post(playlist_resolve_url)) .route("/playlist/update", axum::routing::post(playlist_update)) .route("/playlist/epg", axum::routing::post(playlist_epg)) .route("/playlist/epg/stream", axum::routing::post(stream_epg_api)) .route("/playlist/live", axum::routing::post(playlist_content_live)) .route("/playlist/vod", axum::routing::post(playlist_content_vod)) .route("/playlist/series", axum::routing::post(playlist_content_series)) .route("/playlist/series_info/{virtual_id}/{provider_id}", axum::routing::post(playlist_series_info)) .route("/playlist/series/episode/{virtual_id}", axum::routing::post(playlist_episode_item)) } pub fn v1_api_playlist_register_public( router: Router>, ) -> axum::Router> { router .route("/playlist/resource/{resource}", axum::routing::get(playlist_resource)) .route( "/playlist/webplayer/{token}/{target_id}/{cluster}/{stream_id}", axum::routing::get(playlist_webplayer_stream), ) } pub fn v1_api_playlist_register_with_permissions( router: Router>, app_state: &Arc, ) -> axum::Router> { let read_routes = Router::new() .route("/live", axum::routing::post(playlist_content_live)) .route("/vod", axum::routing::post(playlist_content_vod)) .route("/series", axum::routing::post(playlist_content_series)) .route("/resolve_url", axum::routing::post(playlist_resolve_url)) .route("/series_info/{virtual_id}/{provider_id}", axum::routing::post(playlist_series_info)) .route("/series/episode/{virtual_id}", axum::routing::post(playlist_episode_item)) .layer(permission_layer!(app_state, Permission::PlaylistRead)); let write_routes = Router::new() .route("/update", axum::routing::post(playlist_update)) .layer(permission_layer!(app_state, Permission::PlaylistWrite)); let epg_routes = Router::new() .route("/epg", axum::routing::post(playlist_epg)) .route("/epg/stream", axum::routing::post(stream_epg_api)) .layer(permission_layer!(app_state, Permission::EpgRead)); router.nest("/playlist", read_routes .merge(write_routes) .merge(epg_routes), ) } async fn playlist_episode_item( axum::extract::Path(virtual_id): axum::extract::Path, axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(playlist_req): axum::extract::Json, ) -> impl IntoResponse + Send { if let PlaylistRequest::Target(target_id) = playlist_req { if let Some(target) = app_state.app_config.get_target_by_id(target_id) { if target.has_output(TargetType::Xtream) { if let Ok(vid) = virtual_id.parse::() { if let Ok(pli) = xtream_get_item_for_stream_id(vid, &app_state, &target, Some(XtreamCluster::Series)).await { return axum::Json(json!(UiPlaylistItem::from(pli))).into_response(); } } } } } axum::http::StatusCode::NO_CONTENT.into_response() } #[cfg(test)] mod tests { use super::resolve_provider_url_for_request; use crate::{ api::model::{ ActiveProviderManager, ActiveUserManager, AppState, ConnectionManager, EventManager, MetadataUpdateManager, PlaylistStorageState, SharedStreamManager, }, model::{AppConfig, Config, ConfigInput, ConfigProvider, ConfigSource, ConfigTarget, SourcesConfig, StreamHistoryConfig}, utils::epg::get_input_raw_epg_file_path, utils::GeoIp, }; use arc_swap::{ArcSwap, ArcSwapOption}; use axum::{ body::Body, http::{Request, StatusCode}, Router, }; use shared::foundation::Filter; use shared::{ model::{ConfigPaths, ConfigProviderDto, EpgChannel, EpgConfigDto, EpgProgramme, EpgSourceDto, PlaylistRequest, XtreamCluster}, utils::Internable, }; use std::sync::Arc; use chrono::Utc; use tempfile::tempdir; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tower::ServiceExt; use shared::model::ProcessingOrder; /// Generate an XMLTV datetime string in the format `YYYYMMDDHHmmss +0000` /// offset by `hours_from_now` hours from the current time. fn epg_dt(hours_from_now: i64) -> String { let dt = Utc::now() + chrono::Duration::hours(hours_from_now); dt.format("%Y%m%d%H%M%S %z").to_string() } fn test_app_config(input: Arc, source: ConfigSource) -> AppConfig { let sources = SourcesConfig { batch_files: vec![], provider: vec![], inputs: vec![input], sources: vec![source], templates: None, }; AppConfig { config: Arc::new(ArcSwap::from_pointee(Config::default())), sources: Arc::new(ArcSwap::from_pointee(sources)), hdhomerun: Arc::new(ArcSwapOption::empty()), api_proxy: Arc::new(ArcSwapOption::empty()), file_locks: Arc::new(crate::utils::FileLockManager::default()), paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { home_path: String::new(), config_path: String::new(), storage_path: String::new(), config_file_path: String::new(), sources_file_path: String::new(), mapping_file_path: None, mapping_files_used: None, template_file_path: None, template_files_used: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), custom_stream_response: Arc::new(ArcSwapOption::empty()), access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(crate::model::MediaToolCapabilities::default()), } } fn test_app_state(app_cfg: Arc) -> Arc { let event_manager = Arc::new(EventManager::new()); let active_provider = Arc::new(ActiveProviderManager::new(&app_cfg, &event_manager)); let shared_stream_manager = Arc::new(SharedStreamManager::new(Arc::clone(&active_provider))); let history_config = Some(StreamHistoryConfig::default()); active_provider.set_shared_stream_manager(Arc::clone(&shared_stream_manager)); let geoip = Arc::new(ArcSwapOption::::default()); let config = app_cfg.config.load(); let active_users = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); let connection_manager = Arc::new(ConnectionManager::new( &active_users, &active_provider, &shared_stream_manager, &event_manager, history_config.as_ref(), )); let tokens = crate::api::model::CancelTokens { scheduler: CancellationToken::new(), hdhomerun: CancellationToken::new(), file_watch: CancellationToken::new(), provider_dns: CancellationToken::new(), metadata: CancellationToken::new(), qos_aggregation: CancellationToken::new(), downloads: CancellationToken::new(), }; let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone())); let (manual_update_sender, _) = mpsc::channel::(1); Arc::new(AppState { forced_targets: Arc::new(ArcSwap::from_pointee(crate::model::ProcessTargets { enabled: false, inputs: Vec::new(), targets: Vec::new(), target_names: Vec::new(), })), app_config: app_cfg, http_client: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), downloads: Arc::new(crate::api::model::DownloadQueue::new()), cache: Arc::new(ArcSwapOption::default()), shared_stream_manager, active_users, active_provider, connection_manager, event_manager, cancel_tokens: Arc::new(ArcSwap::from_pointee(tokens)), playlists: Arc::new(PlaylistStorageState::new()), geoip, update_guard: crate::api::model::UpdateGuard::new(), metadata_manager, manual_update_sender, }) } #[test] fn resolve_provider_url_for_input_request_rewrites_provider_scheme() { let provider = ConfigProvider::from(&ConfigProviderDto { name: "demo".intern(), urls: vec!["http://provider.example".intern()], provider_url_selection_policy: shared::model::ProviderUrlSelectionPolicy::default(), dns: None, }); let input = Arc::new(ConfigInput { id: 7, name: "input".intern(), provider_configs: Some(vec![Arc::new(provider)]), ..Default::default() }); let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![] }; let app_config = test_app_config(input, source); let resolved = resolve_provider_url_for_request( &app_config, &PlaylistRequest::Input("input".to_string()), "provider://demo/live/user/pass/1359.ts", ); assert_eq!(resolved, "http://provider.example/live/user/pass/1359.ts"); } #[test] fn resolve_provider_url_for_target_request_rewrites_provider_scheme() { let provider = ConfigProvider::from(&ConfigProviderDto { name: "demo".intern(), urls: vec!["http://provider.example".intern()], provider_url_selection_policy: shared::model::ProviderUrlSelectionPolicy::default(), dns: None, }); let input = Arc::new(ConfigInput { id: 7, name: "input".intern(), provider_configs: Some(vec![Arc::new(provider)]), ..Default::default() }); let target = Arc::new(ConfigTarget { id: 11, enabled: true, name: "target".to_string(), options: None, sort: None, filter: Filter::default(), output: vec![], rename: None, mapping_ids: None, mapping: Arc::default(), favourites: None, processing_order: ProcessingOrder::default(), watch: None, use_memory_cache: false, }); let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![target] }; let app_config = test_app_config(input, source); let resolved = resolve_provider_url_for_request( &app_config, &PlaylistRequest::Target(11), "provider://demo/live/user/pass/1359.ts", ); assert_eq!(resolved, "http://provider.example/live/user/pass/1359.ts"); } #[test] fn resolve_provider_url_passthrough_for_unresolved_provider_input_request() { let provider = ConfigProvider::from(&ConfigProviderDto { name: "demo".intern(), urls: vec!["http://provider.example".intern()], provider_url_selection_policy: shared::model::ProviderUrlSelectionPolicy::default(), dns: None, }); let input = Arc::new(ConfigInput { id: 7, name: "input".intern(), provider_configs: Some(vec![Arc::new(provider)]), ..Default::default() }); let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![] }; let app_config = test_app_config(input, source); let original = "provider://unknown/live/user/pass/1359.ts"; let resolved = resolve_provider_url_for_request(&app_config, &PlaylistRequest::Input("input".to_string()), original); assert_eq!(resolved, original); } #[test] fn resolve_provider_url_passthrough_for_unresolved_provider_target_request() { let provider = ConfigProvider::from(&ConfigProviderDto { name: "demo".intern(), urls: vec!["http://provider.example".intern()], provider_url_selection_policy: shared::model::ProviderUrlSelectionPolicy::default(), dns: None, }); let input = Arc::new(ConfigInput { id: 7, name: "input".intern(), provider_configs: Some(vec![Arc::new(provider)]), ..Default::default() }); let target = Arc::new(ConfigTarget { id: 11, enabled: true, name: "target".to_string(), options: None, sort: None, filter: Filter::default(), output: vec![], rename: None, mapping_ids: None, mapping: Arc::default(), favourites: None, processing_order: ProcessingOrder::default(), watch: None, use_memory_cache: false, }); let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![target] }; let app_config = test_app_config(input, source); let original = "provider://unknown/live/user/pass/1359.ts"; let resolved = resolve_provider_url_for_request(&app_config, &PlaylistRequest::Target(11), original); assert_eq!(resolved, original); } #[test] fn resolve_provider_url_passthrough_for_ambiguous_target_request() { let provider_a = ConfigProvider::from(&ConfigProviderDto { name: "shared".intern(), urls: vec!["http://provider-a.example".intern()], provider_url_selection_policy: shared::model::ProviderUrlSelectionPolicy::default(), dns: None, }); let provider_b = ConfigProvider::from(&ConfigProviderDto { name: "shared".intern(), urls: vec!["http://provider-b.example".intern()], provider_url_selection_policy: shared::model::ProviderUrlSelectionPolicy::default(), dns: None, }); let input_a = Arc::new(ConfigInput { id: 7, name: "input-a".intern(), provider_configs: Some(vec![Arc::new(provider_a)]), ..Default::default() }); let input_b = Arc::new(ConfigInput { id: 8, name: "input-b".intern(), provider_configs: Some(vec![Arc::new(provider_b)]), ..Default::default() }); let target = Arc::new(ConfigTarget { id: 11, enabled: true, name: "target".to_string(), options: None, sort: None, filter: Filter::default(), output: vec![], rename: None, mapping_ids: None, mapping: Arc::default(), favourites: None, processing_order: ProcessingOrder::default(), watch: None, use_memory_cache: false, }); let source = ConfigSource { inputs: vec![Arc::clone(&input_a.name), Arc::clone(&input_b.name)], targets: vec![target], }; let sources = SourcesConfig { batch_files: vec![], provider: vec![], inputs: vec![input_a, input_b], sources: vec![source], templates: None, }; let app_config = AppConfig { config: Arc::new(ArcSwap::from_pointee(Config::default())), sources: Arc::new(ArcSwap::from_pointee(sources)), hdhomerun: Arc::new(ArcSwapOption::empty()), api_proxy: Arc::new(ArcSwapOption::empty()), file_locks: Arc::new(crate::utils::FileLockManager::default()), paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { home_path: String::new(), config_path: String::new(), storage_path: String::new(), config_file_path: String::new(), sources_file_path: String::new(), mapping_file_path: None, mapping_files_used: None, template_file_path: None, template_files_used: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), custom_stream_response: Arc::new(ArcSwapOption::empty()), access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(crate::model::MediaToolCapabilities::default()), }; let original = "provider://shared/live/user/pass/1359.ts"; let resolved = resolve_provider_url_for_request(&app_config, &PlaylistRequest::Target(11), original); assert_eq!(resolved, original); } #[test] fn build_playlist_webplayer_url_uses_cluster_stream_type() { let live = super::build_playlist_webplayer_url("http://player.example", "token123", 1, 42, XtreamCluster::Live); let movie = super::build_playlist_webplayer_url("http://player.example", "token123", 1, 42, XtreamCluster::Video); let series = super::build_playlist_webplayer_url("http://player.example", "token123", 1, 42, XtreamCluster::Series); assert_eq!(live, "http://player.example/api/v1/playlist/webplayer/token123/1/live/42"); assert_eq!(movie, "http://player.example/api/v1/playlist/webplayer/token123/1/movie/42"); assert_eq!(series, "http://player.example/api/v1/playlist/webplayer/token123/1/series/42"); } #[test] fn merge_epg_channels_prefers_higher_priority_metadata_and_fills_lower_priority_gaps() { let low_priority = EpgChannel { id: "demo.channel".intern(), title: Some("Low".intern()), icon: Some("http://low/icon.png".intern()), programmes: vec![EpgProgramme::new_all( 10, 20, "demo.channel".intern(), Some("Low Show".intern()), None, None, )], }; let high_priority = EpgChannel { id: "demo.channel".intern(), title: Some("High".intern()), icon: Some("http://high/icon.png".intern()), programmes: vec![EpgProgramme::new_all( 30, 40, "demo.channel".intern(), Some("High Show".intern()), None, None, )], }; let same_priority = EpgChannel { id: "demo.channel".intern(), title: Some("Same".intern()), icon: Some("http://same/icon.png".intern()), programmes: vec![ EpgProgramme::new_all(30, 40, "demo.channel".intern(), Some("Duplicate".intern()), None, None), EpgProgramme::new_all(50, 60, "demo.channel".intern(), Some("Second Show".intern()), None, None), ], }; let channels = super::merge_epg_channels(vec![(10, vec![low_priority]), (0, vec![high_priority]), (0, vec![same_priority])]); assert_eq!(channels.len(), 1); assert_eq!(channels[0].title.as_deref(), Some("High")); assert_eq!(channels[0].icon.as_deref(), Some("http://high/icon.png")); assert_eq!(channels[0].programmes.len(), 3); assert_eq!( channels[0] .programmes .iter() .map(|programme| (programme.start, programme.stop)) .collect::>(), vec![(10, 20), (30, 40), (50, 60)], ); } #[tokio::test] async fn load_epg_channels_for_input_uses_resolved_provider_epg_cache_file() { let temp_dir = tempdir().expect("temp dir"); let provider = ConfigProvider::from(&ConfigProviderDto { name: "demo".intern(), urls: vec!["http://provider.example".intern()], provider_url_selection_policy: shared::model::ProviderUrlSelectionPolicy::default(), dns: None, }); let input = Arc::new(ConfigInput { id: 7, name: "input".intern(), epg: Some(crate::model::EpgConfig::from(&EpgConfigDto { sources: Some(vec![EpgSourceDto { url: "provider://demo/xmltv.php?username=user&password=pass".to_string(), priority: 0, logo_override: false, }]), t_sources: vec![EpgSourceDto { url: "provider://demo/xmltv.php?username=user&password=pass".to_string(), priority: 0, logo_override: false, }], smart_match: None, })), provider_configs: Some(vec![Arc::new(provider)]), ..Default::default() }); let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![], }; let app_config = test_app_config(input.clone(), source); app_config.config.store(Arc::new(Config { storage_dir: temp_dir.path().to_string_lossy().to_string(), ..Default::default() })); let raw_epg_path = get_input_raw_epg_file_path( "http://provider.example/xmltv.php?username=user&password=pass", input.as_ref(), temp_dir.path().to_string_lossy().as_ref(), ) .await .expect("epg path"); if let Some(parent) = raw_epg_path.parent() { tokio::fs::create_dir_all(parent).await.expect("epg dir"); } let prog_start = epg_dt(0); let prog_stop = epg_dt(1); tokio::fs::write( &raw_epg_path, format!( r#" Demo Channel Morning Show "# ), ) .await .expect("write epg"); let app_state = test_app_state(Arc::new(app_config)); let channels = super::load_epg_channels_for_input(&app_state, input.as_ref()) .await .expect("load epg") .expect("channels"); assert_eq!(channels.len(), 1); assert_eq!(channels[0].id.as_ref(), "demo.channel"); assert_eq!(channels[0].programmes.len(), 1); assert_eq!( channels[0].programmes[0].title.as_ref().map(std::convert::AsRef::as_ref), Some("Morning Show") ); } #[tokio::test] async fn playlist_epg_custom_route_rejects_invalid_url_scheme() { let input = Arc::new(ConfigInput { id: 7, name: "input".intern(), ..Default::default() }); let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![], }; let app_state = test_app_state(Arc::new(test_app_config(input, source))); let router = super::v1_api_playlist_register_protected(Router::new()).with_state(app_state); let request = Request::builder() .method("POST") .uri("/playlist/epg") .header("content-type", "application/json") .body(Body::from(r#"{"Custom":"ftp://example.com/epg.xml"}"#)) .expect("request"); let response = router.into_service::().oneshot(request).await.expect("response"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn playlist_epg_input_route_returns_cached_provider_epg() { let temp_dir = tempdir().expect("temp dir"); let provider = ConfigProvider::from(&ConfigProviderDto { name: "demo".intern(), urls: vec!["http://provider.example".intern()], provider_url_selection_policy: shared::model::ProviderUrlSelectionPolicy::default(), dns: None, }); let input = Arc::new(ConfigInput { id: 7, name: "input".intern(), epg: Some(crate::model::EpgConfig::from(&EpgConfigDto { sources: Some(vec![EpgSourceDto { url: "provider://demo/xmltv.php?username=user&password=pass".to_string(), priority: 0, logo_override: false, }]), t_sources: vec![EpgSourceDto { url: "provider://demo/xmltv.php?username=user&password=pass".to_string(), priority: 0, logo_override: false, }], smart_match: None, })), provider_configs: Some(vec![Arc::new(provider)]), ..Default::default() }); let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![], }; let app_config = test_app_config(input.clone(), source); app_config.config.store(Arc::new(Config { storage_dir: temp_dir.path().to_string_lossy().to_string(), ..Default::default() })); let raw_epg_path = get_input_raw_epg_file_path( "http://provider.example/xmltv.php?username=user&password=pass", input.as_ref(), temp_dir.path().to_string_lossy().as_ref(), ) .await .expect("epg path"); if let Some(parent) = raw_epg_path.parent() { tokio::fs::create_dir_all(parent).await.expect("epg dir"); } let prog_start = epg_dt(0); let prog_stop = epg_dt(1); tokio::fs::write( &raw_epg_path, format!( r#" Demo Channel Morning Show "# ), ) .await .expect("write epg"); let app_state = test_app_state(Arc::new(app_config)); let router = super::v1_api_playlist_register_protected(Router::new()).with_state(app_state); let request = Request::builder() .method("POST") .uri("/playlist/epg") .header("content-type", "application/json") .body(Body::from(r#"{"Input":"input"}"#)) .expect("request"); let response = router.into_service::().oneshot(request).await.expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("body"); let body_text = String::from_utf8(body.to_vec()).expect("utf8"); assert!(body_text.contains("demo.channel"), "{body_text}"); assert!(body_text.contains("Morning Show"), "{body_text}"); } #[tokio::test] async fn playlist_epg_input_route_returns_no_content_for_unknown_input_name() { let provider = ConfigProvider::from(&ConfigProviderDto { name: "demo".intern(), urls: vec!["http://provider.example".intern()], provider_url_selection_policy: shared::model::ProviderUrlSelectionPolicy::default(), dns: None, }); let input = Arc::new(ConfigInput { id: 7, name: "input".intern(), provider_configs: Some(vec![Arc::new(provider)]), ..Default::default() }); let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![], }; let app_state = test_app_state(Arc::new(test_app_config(input, source))); let router = super::v1_api_playlist_register_protected(Router::new()).with_state(app_state); let request = Request::builder() .method("POST") .uri("/playlist/epg") .header("content-type", "application/json") .body(Body::from(r#"{"Input":"missing"}"#)) .expect("request"); let response = router.into_service::().oneshot(request).await.expect("response"); assert_eq!(response.status(), StatusCode::NO_CONTENT); } #[tokio::test] #[allow(clippy::too_many_lines)] async fn playlist_epg_input_route_merges_multiple_cached_sources_by_priority() { let temp_dir = tempdir().expect("temp dir"); let provider = ConfigProvider::from(&ConfigProviderDto { name: "demo".intern(), urls: vec!["http://provider.example".intern()], provider_url_selection_policy: shared::model::ProviderUrlSelectionPolicy::default(), dns: None, }); let primary_url = "provider://demo/xmltv-primary.php?username=user&password=pass"; let secondary_url = "provider://demo/xmltv-secondary.php?username=user&password=pass"; let input = Arc::new(ConfigInput { id: 7, name: "input".intern(), epg: Some(crate::model::EpgConfig::from(&EpgConfigDto { sources: Some(vec![ EpgSourceDto { url: secondary_url.to_string(), priority: 10, logo_override: false, }, EpgSourceDto { url: primary_url.to_string(), priority: 0, logo_override: false, }, EpgSourceDto { url: "provider://demo/xmltv-same-priority.php?username=user&password=pass".to_string(), priority: 0, logo_override: false, }, ]), t_sources: vec![ EpgSourceDto { url: secondary_url.to_string(), priority: 10, logo_override: false, }, EpgSourceDto { url: primary_url.to_string(), priority: 0, logo_override: false, }, EpgSourceDto { url: "provider://demo/xmltv-same-priority.php?username=user&password=pass".to_string(), priority: 0, logo_override: false, }, ], smart_match: None, })), provider_configs: Some(vec![Arc::new(provider)]), ..Default::default() }); let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![], }; let app_config = test_app_config(input.clone(), source); app_config.config.store(Arc::new(Config { storage_dir: temp_dir.path().to_string_lossy().to_string(), ..Default::default() })); let primary_path = get_input_raw_epg_file_path( "http://provider.example/xmltv-primary.php?username=user&password=pass", input.as_ref(), temp_dir.path().to_string_lossy().as_ref(), ) .await .expect("primary epg path"); let secondary_path = get_input_raw_epg_file_path( "http://provider.example/xmltv-secondary.php?username=user&password=pass", input.as_ref(), temp_dir.path().to_string_lossy().as_ref(), ) .await .expect("secondary epg path"); let same_priority_path = get_input_raw_epg_file_path( "http://provider.example/xmltv-same-priority.php?username=user&password=pass", input.as_ref(), temp_dir.path().to_string_lossy().as_ref(), ) .await .expect("same priority epg path"); for path in [&primary_path, &secondary_path, &same_priority_path] { if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await.expect("epg dir"); } } let p0 = epg_dt(0); let p1 = epg_dt(1); let p2 = epg_dt(2); let p3 = epg_dt(3); tokio::fs::write( &secondary_path, format!( r#" Secondary Channel Secondary Show "# ), ) .await .expect("write secondary epg"); tokio::fs::write( &primary_path, format!( r#" Primary Channel Primary Show "# ), ) .await .expect("write primary epg"); tokio::fs::write( &same_priority_path, format!( r#" Same Priority Channel Duplicate Show Second Show "# ), ) .await .expect("write same priority epg"); let app_state = test_app_state(Arc::new(app_config)); let router = super::v1_api_playlist_register_protected(Router::new()).with_state(app_state); let request = Request::builder() .method("POST") .uri("/playlist/epg") .header("content-type", "application/json") .body(Body::from(r#"{"Input":"input"}"#)) .expect("request"); let response = router.into_service::().oneshot(request).await.expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("body"); let body_text = String::from_utf8(body.to_vec()).expect("utf8"); assert!(body_text.contains("Primary Channel"), "{body_text}"); assert!(!body_text.contains("Secondary Channel"), "{body_text}"); assert!(body_text.contains("Primary Show"), "{body_text}"); assert!(body_text.contains("Second Show"), "{body_text}"); assert!(!body_text.contains("Secondary Show"), "{body_text}"); } }