use super::{ empty_stream_info_response, get_xtream_player_api_stream_url, is_hls_playback_request, override_live_hls_extension, recording_input_matches, resolve_m3u_xtream_timeshift, resolve_xtream_playback_extension, xtream_get_short_epg, xtream_player_api_stream, xtream_player_api_stream_with_token, ApiStreamContext, ApiStreamRequest, XtreamApiTimeShiftRequest, }; use crate::{ api::model::{create_test_app_state, AppState, PlaylistStorage, PlaylistXtreamStorage, UserApiRequest}, auth::Fingerprint, model::{ Config, ConfigInput, ConfigTarget, Epg, IcsEpgSourceConfig, ProxyUserCredentials, SourcesConfig, TargetOutput, XtreamTargetFlagsSet, XtreamTargetOutput, }, processing::parser::ics::parse_ics_file_to_channel, repository::{ epg_write_file, xtream_get_epg_file_path_for_target, xtream_get_storage_path, BPlusTree, VirtualIdRecord, }, }; use arc_swap::ArcSwapOption; use axum::{http::HeaderMap, response::IntoResponse}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine}; use shared::{ foundation::Filter, model::{ ClusterFlags, InputType, PlaylistItemType, ProcessingOrder, ProxyUserStatus, StreamProperties, UUIDType, VideoStreamProperties, VirtualId, XtreamCluster, XtreamPlaylistItem, }, utils::Internable, }; use std::sync::Arc; use tempfile::tempdir; #[test] fn recording_input_must_match_canonical_playlist_item_input() { let expected = ConfigInput { name: "input-a".intern(), ..Default::default() }; assert!(recording_input_matches(Some(&expected), "input-a")); assert!(!recording_input_matches(Some(&expected), "input-b")); assert!(recording_input_matches(None, "input-b")); } #[test] fn live_hls_override_is_scoped_to_enabled_xtream_live_requests() { let enabled_xtream = provider_input_with_flag(InputType::Xtream, true); let disabled_xtream = provider_input_with_flag(InputType::Xtream, false); let enabled_m3u = provider_input_with_flag(InputType::M3u, true); let cases = [ (ApiStreamContext::Live, &enabled_xtream, Some(".m3u8"), Some(".ts")), (ApiStreamContext::LiveAlt, &enabled_xtream, Some(".m3u8"), Some(".ts")), (ApiStreamContext::Live, &disabled_xtream, Some(".m3u8"), Some(".m3u8")), (ApiStreamContext::Live, &enabled_m3u, Some(".m3u8"), Some(".m3u8")), (ApiStreamContext::Timeshift, &enabled_xtream, Some(".m3u8"), Some(".m3u8")), (ApiStreamContext::Movie, &enabled_xtream, Some(".m3u8"), Some(".m3u8")), (ApiStreamContext::Series, &enabled_xtream, Some(".m3u8"), Some(".m3u8")), (ApiStreamContext::Live, &enabled_xtream, Some(".ts"), Some(".ts")), (ApiStreamContext::Live, &enabled_xtream, Some(".mpd"), Some(".mpd")), (ApiStreamContext::Live, &enabled_xtream, None, None), ]; for (context, input, extension, expected) in cases { assert_eq!( override_live_hls_extension(context, input, extension), expected, "case: {context:?} input_type={:?} ext={extension:?}", input.input_type ); } } fn provider_input_with_flag(input_type: InputType, disable_hls: bool) -> ConfigInput { let dto = shared::model::ConfigInputDto { name: "ts-provider".into(), input_type, url: "http://provider.test".to_string(), username: Some("user".to_string()), password: Some("pass".to_string()), enabled: true, options: Some(shared::model::ConfigInputOptionsDto { disable_hls_streaming: disable_hls, ..shared::model::ConfigInputOptionsDto::default() }), ..shared::model::ConfigInputDto::default() }; ConfigInput::from(&dto) } #[test] fn hls_failure_response_uses_resolved_playback_extension() { let vod = create_test_vod_item("provider://strong/movie/user/pass/813563.mp4", "mp4", PlaylistItemType::Video); let hls_vod = create_test_vod_item("provider://strong/movie/user/pass/813564.m3u8", "m3u8", PlaylistItemType::Video); assert!(!is_hls_playback_request(Some(".m3u8"), &vod)); assert!(is_hls_playback_request(None, &hls_vod)); } async fn response_body_text(response: axum::response::Response) -> Result { let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .map_err(|err| format!("failed to read response body: {err}"))?; String::from_utf8(body.to_vec()).map_err(|err| format!("response body is not UTF-8: {err}")) } fn short_epg_target() -> ConfigTarget { ConfigTarget { curation: None, id: 1, enabled: true, name: "ics-xtream".to_string(), options: None, sort: None, filter: Filter::default().into(), output: vec![TargetOutput::Xtream(XtreamTargetOutput { flags: XtreamTargetFlagsSet::new(), trakt: None, filter: None, })], rename: None, mapping_ids: None, mapping: Arc::new(ArcSwapOption::new(None)), favourites: None, processing_order: ProcessingOrder::default(), execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: true, } } fn short_epg_live_item() -> XtreamPlaylistItem { XtreamPlaylistItem { virtual_id: VirtualId::new(100), provider_id: 0, name: "Formula 1".intern(), logo: "".intern(), logo_small: "".intern(), group: "Sports".intern(), title: "".intern(), parent_code: "".intern(), rec: "".intern(), url: "http://example.invalid/live.ts".intern(), epg_channel_id: Some("f1.calendar".intern()), xtream_cluster: XtreamCluster::Live, additional_properties: None, item_type: PlaylistItemType::Live, category_id: 1, input_name: "local".intern(), channel_no: 1, source_ordinal: 0, input_stream_id: "".intern(), upstream_user_agent: None, } } #[tokio::test] async fn xtream_short_epg_returns_imported_ics_programme_for_matching_channel_id() { let dir = tempdir().expect("temp dir"); let ics_path = dir.path().join("calendar.ics"); let start = chrono::Utc::now() + chrono::Duration::days(1); let stop = start + chrono::Duration::hours(1); std::fs::write( &ics_path, format!( concat!( "BEGIN:VCALENDAR\r\n", "VERSION:2.0\r\n", "BEGIN:VEVENT\r\n", "UID:f1-qualifying\r\n", "DTSTART:{}\r\n", "DTEND:{}\r\n", "SUMMARY:Formula 1 Qualifying\r\n", "DESCRIPTION:Imported from ICS\r\n", "END:VEVENT\r\n", "END:VCALENDAR\r\n", ), start.format("%Y%m%dT%H%M%SZ"), stop.format("%Y%m%dT%H%M%SZ"), ), ) .expect("write ICS fixture"); let channel = parse_ics_file_to_channel( &ics_path, "f1.calendar".intern(), Some("Formula 1".intern()), &IcsEpgSourceConfig::default(), ) .await .expect("parse ICS fixture"); let config = Config { storage_dir: dir.path().to_string_lossy().into_owned(), ..Config::default() }; let target = Arc::new(short_epg_target()); let xtream_storage = xtream_get_storage_path(&config, &target.name).expect("xtream storage path"); std::fs::create_dir_all(&xtream_storage).expect("create xtream storage"); let epg_path = xtream_get_epg_file_path_for_target(&xtream_storage); epg_write_file( &target.name, &Epg { priority: 0, logo_override: false, attributes: None, children: vec![Arc::new(channel)] }, &epg_path, &std::collections::HashMap::, Arc>::new(), None, &shared::model::EpgOutputOptions::default(), ) .expect("write target EPG"); let app_state = create_test_app_state(config); let live_item = short_epg_live_item(); let mut live = BPlusTree::new(); live.insert(live_item.virtual_id.get(), live_item.clone()); app_state .playlists .cache_playlist( &target.name, PlaylistStorage::XtreamPlaylist(Box::new(PlaylistXtreamStorage { live, vod: BPlusTree::new(), series: BPlusTree::new(), })), ) .await; let mut id_mapping = BPlusTree::new(); id_mapping.insert( live_item.virtual_id, VirtualIdRecord::new( live_item.provider_id, live_item.virtual_id, PlaylistItemType::Live, VirtualId::new(0), UUIDType::default(), ), ); app_state.playlists.cache_id_mapping(&target.name, id_mapping).await; let mut user = ProxyUserCredentials::default(); user.output_clusters = ClusterFlags::all(); let response = xtream_get_short_epg(&app_state, &user, &target, "100", 4).await.into_response(); let body = response_body_text(response).await.expect("read short EPG response"); let json: serde_json::Value = serde_json::from_str(&body).expect("parse short EPG JSON"); let decode_listing_text = |field: &str| { let encoded = json["epg_listings"][0][field].as_str().expect("encoded listing text"); String::from_utf8(BASE64_STANDARD.decode(encoded).expect("decode listing text")) .expect("decoded listing text is UTF-8") }; assert_eq!(json["epg_listings"][0]["channel_id"], "f1.calendar"); assert_eq!(decode_listing_text("title"), "Formula 1 Qualifying"); assert_eq!(decode_listing_text("description"), "Imported from ICS"); } fn m3u_catchup_item(name: &str, input_name: &str, url: &str, catchup_source: Option<&str>) -> XtreamPlaylistItem { XtreamPlaylistItem { virtual_id: VirtualId::new(100), provider_id: 0, name: name.intern(), logo: "".intern(), logo_small: "".intern(), group: "Live".intern(), title: "".intern(), parent_code: "".intern(), rec: "".intern(), url: url.intern(), epg_channel_id: Some("f1.calendar".intern()), xtream_cluster: XtreamCluster::Live, item_type: PlaylistItemType::Live, category_id: 1, input_name: input_name.intern(), channel_no: 1, source_ordinal: 0, input_stream_id: "100".intern(), additional_properties: Some(StreamProperties::Live(Box::new(shared::model::LiveStreamProperties { name: name.intern(), stream_id: 100, tv_archive: Some(1), tv_archive_duration: Some(7), catchup: catchup_source.map(|src| shared::model::CatchupProperties { mode: Some("flussonic".intern()), source: Some(src.intern()), ..shared::model::CatchupProperties::default() }), ..shared::model::LiveStreamProperties::default() }))), upstream_user_agent: None, } } async fn cache_xtream_test_item( app_state: &Arc, target: &Arc, item: XtreamPlaylistItem, input: Option, ) { if let Some(input) = input { let sources = SourcesConfig { inputs: vec![Arc::new(input)], sources: vec![crate::model::ConfigSource { inputs: vec![item.input_name.clone()], targets: vec![target.clone()], }], ..SourcesConfig::default() }; app_state.app_config.sources.store(Arc::new(sources)); } let mut live = BPlusTree::new(); live.insert(item.virtual_id.get(), item.clone()); app_state .playlists .cache_playlist( &target.name, PlaylistStorage::XtreamPlaylist(Box::new(PlaylistXtreamStorage { live, vod: BPlusTree::new(), series: BPlusTree::new(), })), ) .await; let mut id_mapping = BPlusTree::new(); id_mapping.insert( item.virtual_id, VirtualIdRecord::new( item.provider_id, item.virtual_id, PlaylistItemType::Live, VirtualId::new(0), UUIDType::default(), ), ); app_state.playlists.cache_id_mapping(&target.name, id_mapping).await; } async fn build_short_epg_app_state( dir: &tempfile::TempDir, item: XtreamPlaylistItem, input: Option, ) -> (Arc, Arc) { let ics_path = dir.path().join("calendar.ics"); let start = chrono::Utc::now() + chrono::Duration::days(1); let stop = start + chrono::Duration::hours(1); std::fs::write( &ics_path, format!( concat!( "BEGIN:VCALENDAR\r\n", "VERSION:2.0\r\n", "BEGIN:VEVENT\r\n", "UID:f1-qualifying\r\n", "DTSTART:{}\r\n", "DTEND:{}\r\n", "SUMMARY:Formula 1 Qualifying\r\n", "DESCRIPTION:Imported from ICS\r\n", "END:VEVENT\r\n", "END:VCALENDAR\r\n", ), start.format("%Y%m%dT%H%M%SZ"), stop.format("%Y%m%dT%H%M%SZ"), ), ) .expect("write ICS fixture"); let channel = parse_ics_file_to_channel( &ics_path, "f1.calendar".intern(), Some("Formula 1".intern()), &IcsEpgSourceConfig::default(), ) .await .expect("parse ICS fixture"); let config = Config { storage_dir: dir.path().to_string_lossy().into_owned(), ..Config::default() }; let target = Arc::new(short_epg_target()); let xtream_storage = xtream_get_storage_path(&config, &target.name).expect("xtream storage path"); std::fs::create_dir_all(&xtream_storage).expect("create xtream storage"); let epg_path = xtream_get_epg_file_path_for_target(&xtream_storage); epg_write_file( &target.name, &Epg { priority: 0, logo_override: false, attributes: None, children: vec![Arc::new(channel)] }, &epg_path, &std::collections::HashMap::, Arc>::new(), None, &shared::model::EpgOutputOptions::default(), ) .expect("write target EPG"); let app_state = create_test_app_state(config); cache_xtream_test_item(&app_state, &target, item, input).await; (app_state, target) } #[tokio::test] async fn xtream_short_epg_emits_has_archive_for_m3u_with_bridge_template() { let dir = tempdir().expect("temp dir"); let input = m3u_timeshift_input(); let item = m3u_catchup_item( "Formula 1", &input.name, "channel/index.m3u8", Some("http://provider.example/channel/video-{utc}-{duration}.m3u8"), ); let (app_state, target) = build_short_epg_app_state(&dir, item, Some(input)).await; let mut user = ProxyUserCredentials::default(); user.output_clusters = ClusterFlags::all(); let response = xtream_get_short_epg(&app_state, &user, &target, "100", 4).await.into_response(); let body = response_body_text(response).await.expect("body"); let json: serde_json::Value = serde_json::from_str(&body).expect("json"); assert_eq!(json["epg_listings"][0]["has_archive"], 1); } #[tokio::test] async fn xtream_short_epg_omits_has_archive_for_m3u_with_unsupported_catchup() { let dir = tempdir().expect("temp dir"); let input = m3u_timeshift_input(); let item = m3u_catchup_item( "Formula 1", &input.name, "channel/index.m3u8", Some("http://provider.example/channel/${timestamp}.m3u8"), ); let (app_state, target) = build_short_epg_app_state(&dir, item, Some(input)).await; let mut user = ProxyUserCredentials::default(); user.output_clusters = ClusterFlags::all(); let body = response_body_text(xtream_get_short_epg(&app_state, &user, &target, "100", 4).await.into_response()) .await .expect("body"); let json: serde_json::Value = serde_json::from_str(&body).expect("json"); assert!(json["epg_listings"][0].get("has_archive").is_none()); } #[tokio::test] async fn xtream_short_epg_emits_has_archive_for_native_xtream_input() { let dir = tempdir().expect("temp dir"); let mut input = m3u_timeshift_input(); input.input_type = InputType::Xtream; let item = m3u_catchup_item("Formula 1", &input.name, "channel/index.m3u8", None); let (app_state, target) = build_short_epg_app_state(&dir, item, Some(input)).await; let mut user = ProxyUserCredentials::default(); user.output_clusters = ClusterFlags::all(); let body = response_body_text(xtream_get_short_epg(&app_state, &user, &target, "100", 4).await.into_response()) .await .expect("body"); let json: serde_json::Value = serde_json::from_str(&body).expect("json"); assert_eq!(json["epg_listings"][0]["has_archive"], 1); } #[tokio::test] async fn empty_stream_info_response_uses_vod_object_and_list_shapes() -> Result<(), String> { assert_eq!(response_body_text(empty_stream_info_response(XtreamCluster::Video)).await?, "{}"); assert_eq!(response_body_text(empty_stream_info_response(XtreamCluster::Live)).await?, "[]"); assert_eq!(response_body_text(empty_stream_info_response(XtreamCluster::Series)).await?, "[]"); Ok(()) } fn create_test_vod_item(url: &str, container_extension: &str, item_type: PlaylistItemType) -> XtreamPlaylistItem { XtreamPlaylistItem { virtual_id: VirtualId::new(176_141), provider_id: 813_563, name: "Test".intern(), logo: "".intern(), logo_small: "".intern(), group: "".intern(), title: "".intern(), parent_code: "".intern(), rec: "".intern(), url: Arc::::from(url), epg_channel_id: None, xtream_cluster: XtreamCluster::Video, additional_properties: Some(StreamProperties::Video(Box::new(VideoStreamProperties { name: "Test".intern(), category_id: 0, stream_id: 813_563, stream_icon: "".intern(), direct_source: "".intern(), custom_sid: None, added: "".intern(), container_extension: container_extension.intern(), rating: None, rating_5based: None, stream_type: Some("movie".intern()), trailer: None, tmdb: None, is_adult: 0, details: None, }))), item_type, category_id: 0, input_name: "strong".intern(), channel_no: 0, source_ordinal: 0, input_stream_id: "813563".intern(), upstream_user_agent: None, } } #[test] fn post_query_only_request_prefers_query_when_form_is_missing() { let api_query_req = UserApiRequest { username: String::from("query-user"), password: String::from("query-pass"), action: String::from("get_live_streams"), ..UserApiRequest::default() }; let api_req = UserApiRequest::merge_query_over_form(&api_query_req, None); assert_eq!(api_req.username, "query-user"); assert_eq!(api_req.password, "query-pass"); assert_eq!(api_req.action, "get_live_streams"); } #[test] fn post_request_prefers_query_over_form() { let api_query_req = UserApiRequest { username: String::from("query-user"), action: String::from("query-action"), ..UserApiRequest::default() }; let form_req = UserApiRequest { username: String::from("form-user"), action: String::from("form-action"), ..UserApiRequest::default() }; let api_req = UserApiRequest::merge_query_over_form(&api_query_req, Some(&form_req)); assert_eq!(api_req.username, "query-user"); assert_eq!(api_req.action, "query-action"); } #[test] fn timeshift_query_request_prefers_query_when_form_is_missing() { let api_query_req = UserApiRequest { username: String::from("query-user"), password: String::from("query-pass"), stream: String::from("42"), duration: String::from("60"), start: String::from("2024-01-01:00-00"), ..UserApiRequest::default() }; let api_req = UserApiRequest::merge_query_over_form(&api_query_req, None); assert_eq!(api_req.username, "query-user"); assert_eq!(api_req.password, "query-pass"); assert_eq!(api_req.stream, "42"); assert_eq!(api_req.duration, "60"); assert_eq!(api_req.start, "2024-01-01:00-00"); } #[test] fn timeshift_query_request_prefers_query_over_form() { let api_query_req = UserApiRequest { username: String::from("query-user"), stream: String::from("42"), duration: String::from("60"), start: String::from("2024-01-01:00-00"), ..UserApiRequest::default() }; let form_req = UserApiRequest { username: String::from("form-user"), stream: String::from("99"), duration: String::from("10"), start: String::from("form-start"), ..UserApiRequest::default() }; let api_req = UserApiRequest::merge_query_over_form(&api_query_req, Some(&form_req)); assert_eq!(api_req.username, "query-user"); assert_eq!(api_req.stream, "42"); assert_eq!(api_req.duration, "60"); assert_eq!(api_req.start, "2024-01-01:00-00"); } #[test] fn timeshift_path_request_prefers_query_when_form_is_missing() { let timeshift_request = XtreamApiTimeShiftRequest { username: String::new(), password: String::new(), duration: String::new(), start: String::new(), stream_id: String::new(), }; let api_query_req = UserApiRequest { username: String::from("query-user"), password: String::from("query-pass"), stream_id: String::from("42"), duration: String::from("60"), start: String::from("2024-01-01:00-00"), ..UserApiRequest::default() }; let query_req = UserApiRequest::merge_query_over_form(&api_query_req, None); let path_req = UserApiRequest { username: timeshift_request.username, password: timeshift_request.password, duration: timeshift_request.duration, start: timeshift_request.start, stream_id: timeshift_request.stream_id, ..UserApiRequest::default() }; let api_req = UserApiRequest::merge_prefer_primary(&path_req, &query_req); assert_eq!(api_req.username, "query-user"); assert_eq!(api_req.password, "query-pass"); assert_eq!(api_req.stream_id, "42"); assert_eq!(api_req.duration, "60"); assert_eq!(api_req.start, "2024-01-01:00-00"); } #[test] fn timeshift_path_request_prefers_path_over_query_and_form() { let timeshift_request = XtreamApiTimeShiftRequest { username: String::from("path-user"), password: String::from("path-pass"), duration: String::from("120"), start: String::from("path-start"), stream_id: String::from("7"), }; let api_query_req = UserApiRequest { username: String::from("query-user"), password: String::from("query-pass"), stream_id: String::from("42"), duration: String::from("60"), start: String::from("query-start"), ..UserApiRequest::default() }; let form_req = UserApiRequest { username: String::from("form-user"), password: String::from("form-pass"), stream_id: String::from("99"), duration: String::from("10"), start: String::from("form-start"), ..UserApiRequest::default() }; let query_req = UserApiRequest::merge_query_over_form(&api_query_req, Some(&form_req)); let path_req = UserApiRequest { username: timeshift_request.username, password: timeshift_request.password, duration: timeshift_request.duration, start: timeshift_request.start, stream_id: timeshift_request.stream_id, ..UserApiRequest::default() }; let api_req = UserApiRequest::merge_prefer_primary(&path_req, &query_req); assert_eq!(api_req.username, "path-user"); assert_eq!(api_req.password, "path-pass"); assert_eq!(api_req.stream_id, "7"); assert_eq!(api_req.duration, "120"); assert_eq!(api_req.start, "path-start"); } #[test] fn non_live_playback_extension_prefers_canonical_item_extension_over_client_override() { let pli = create_test_vod_item("provider://strong/movie/user/pass/813563.mp4", "mp4", PlaylistItemType::Video); assert_eq!(resolve_xtream_playback_extension(Some(".mkv"), &pli).as_deref(), Some(".mp4")); } #[test] fn media_server_xtream_playback_uses_internal_stream_ref_even_with_direct_pms_url_credentials() { let input = ConfigInput { input_type: InputType::Plex, url: "http://pms-user:pms-pass@pms.example.invalid:32400".to_string(), ..ConfigInput::default() }; let fallback = Arc::::from("media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted%2Ffile.mkv"); let resolved = get_xtream_player_api_stream_url(&input, ApiStreamContext::Movie, "813563.mkv", &fallback) .expect("media-server fallback should be preserved"); assert_eq!(resolved, fallback); } #[test] fn live_playback_extension_keeps_client_requested_extension() { let mut pli = create_test_vod_item("provider://strong/live/user/pass/813563.ts", "ts", PlaylistItemType::Live); pli.xtream_cluster = XtreamCluster::Live; assert_eq!(resolve_xtream_playback_extension(Some(".m3u8"), &pli).as_deref(), Some(".m3u8")); } #[test] fn catchup_playback_extension_preserves_requested_adaptive_extension_for_underlying_live_item() { let mut pli = create_test_vod_item("provider://strong/live/user/pass/813563.ts", "ts", PlaylistItemType::Live); pli.xtream_cluster = XtreamCluster::Live; assert_eq!(resolve_xtream_playback_extension(Some(".m3u8"), &pli).as_deref(), Some(".m3u8")); } fn m3u_timeshift_input() -> ConfigInput { ConfigInput { id: 0, name: "m3u-flussonic".intern(), input_type: InputType::M3u, url: "http://provider.example".to_string(), username: Some("alice".to_string()), password: Some("secret".to_string()), ..ConfigInput::default() } } fn m3u_timeshift_item() -> XtreamPlaylistItem { XtreamPlaylistItem { virtual_id: VirtualId::new(100), provider_id: 0, name: "Live TV".intern(), logo: "".intern(), logo_small: "".intern(), group: "Live".intern(), title: "".intern(), parent_code: "".intern(), rec: "".intern(), url: "channel/index.m3u8".intern(), epg_channel_id: None, xtream_cluster: XtreamCluster::Live, additional_properties: Some(StreamProperties::Live(Box::new(shared::model::LiveStreamProperties { name: "Live TV".intern(), stream_id: 100, tv_archive: Some(1), tv_archive_duration: Some(7), catchup: Some(shared::model::CatchupProperties { mode: Some("flussonic".intern()), source: Some("http://provider.example/channel/video-{utc}-{duration}.m3u8".intern()), ..shared::model::CatchupProperties::default() }), ..shared::model::LiveStreamProperties::default() }))), item_type: PlaylistItemType::Live, category_id: 1, input_name: "m3u-flussonic".intern(), channel_no: 1, source_ordinal: 0, input_stream_id: "100".intern(), upstream_user_agent: None, } } #[test] fn m3u_timeshift_resolver_returns_resolved_url_for_flussonic_template() { let input = m3u_timeshift_input(); let item = m3u_timeshift_item(); let resolved = resolve_m3u_xtream_timeshift(&input, &item, "60/2024-01-01:00-00") .expect("resolver ok") .expect("M3U input handled by bridge"); assert_eq!(resolved.url, "http://provider.example/channel/video-1704067200-3600.m3u8"); assert!(!resolved.discriminator.is_empty()); } #[test] fn m3u_timeshift_resolver_distinguishes_time_windows() { let input = m3u_timeshift_input(); let item = m3u_timeshift_item(); let first = resolve_m3u_xtream_timeshift(&input, &item, "60/2024-01-01:00-00").expect("first ok").expect("some"); let second = resolve_m3u_xtream_timeshift(&input, &item, "60/2024-01-01:01-00").expect("second ok").expect("some"); assert_ne!(first.url, second.url); assert_ne!(first.discriminator, second.discriminator); let fp = Fingerprint::new("fp".to_string(), "127.0.0.1".to_string(), "127.0.0.1:0".parse().unwrap()); assert_ne!( crate::api::api_utils::create_m3u_catchup_session_key(&fp, "alice", 100, &first.discriminator), crate::api::api_utils::create_m3u_catchup_session_key(&fp, "alice", 100, &second.discriminator), ); } #[test] fn m3u_timeshift_resolver_rejects_missing_catchup_metadata() { let mut item = m3u_timeshift_item(); if let Some(StreamProperties::Live(live)) = item.additional_properties.as_mut() { live.catchup = None; } let input = m3u_timeshift_input(); let err = resolve_m3u_xtream_timeshift(&input, &item, "60/2024-01-01:00-00").expect_err("missing catchup must error"); assert_eq!(err.kind(), shared::error::ErrorKind::ApiXtream); } #[test] fn m3u_timeshift_resolver_returns_none_for_non_m3u_input() { let mut input = m3u_timeshift_input(); input.input_type = InputType::Xtream; let item = m3u_timeshift_item(); let resolved = resolve_m3u_xtream_timeshift(&input, &item, "60/2024-01-01:00-00") .expect("non-M3U inputs are skipped, not errored"); assert!(resolved.is_none(), "non-M3U input must be skipped by helper"); } #[test] fn m3u_timeshift_stream_url_uses_resolved_archive_for_credential_bearing_input() { let input = m3u_timeshift_input(); let resolved_url: Arc = Arc::from("http://provider.example/channel/video-1704067200-3600.m3u8"); let url = get_xtream_player_api_stream_url(&input, ApiStreamContext::Timeshift, "60/2024-01-01:00-00", &resolved_url) .expect("resolved archive URL must win for M3U timeshift"); assert_eq!(url, resolved_url); assert!(!url.contains("alice")); assert!(!url.contains("secret")); } #[tokio::test] async fn expired_user_is_rejected_before_m3u_timeshift_is_resolved() -> Result<(), String> { let dir = tempdir().map_err(|err| err.to_string())?; let input = m3u_timeshift_input(); let item = m3u_timeshift_item(); let config = Config { storage_dir: dir.path().to_string_lossy().into_owned(), user_access_control: true, ..Config::default() }; let app_state = create_test_app_state(config); let target = Arc::new(short_epg_target()); cache_xtream_test_item(&app_state, &target, item, Some(input)).await; let mut user = ProxyUserCredentials::default(); user.username = "expired".to_string(); user.status = Some(ProxyUserStatus::Expired); user.output_clusters = ClusterFlags::all(); let user = Arc::new(user); let addr = "127.0.0.1:0".parse().map_err(|err: std::net::AddrParseError| err.to_string())?; let fingerprint = Fingerprint::new("fp".to_string(), "127.0.0.1".to_string(), addr); let response = xtream_player_api_stream( &fingerprint, &HeaderMap::new(), &app_state, &UserApiRequest::default(), ApiStreamRequest::from(ApiStreamContext::Timeshift, "expired", "", "100.ts", "invalid"), Some((user, target)), ) .await .into_response(); assert_eq!(response.status(), axum::http::StatusCode::BAD_GATEWAY); Ok(()) } #[tokio::test] async fn xtream_token_stream_rejects_m3u_timeshift_with_bad_request() { use crate::auth::create_access_token; let dir = tempdir().expect("temp dir"); let config = Config { storage_dir: dir.path().to_string_lossy().into_owned(), ..Config::default() }; let app_state = create_test_app_state(config); let target = Arc::new(short_epg_target()); let input = m3u_timeshift_input(); let item = m3u_timeshift_item(); cache_xtream_test_item(&app_state, &target, item, Some(input)).await; let token = create_access_token(&app_state.app_config.access_token_secret, 60, crate::auth::scope::INTERNAL_PLAYER); let response = xtream_player_api_stream_with_token( &Fingerprint::new("fp".to_string(), "127.0.0.1".to_string(), "127.0.0.1:0".parse().unwrap()), &HeaderMap::new(), &app_state, target.id, ApiStreamRequest::from_access_token(ApiStreamContext::Timeshift, &token, "100.ts", ""), ) .await .into_response(); assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST); } /// Serves a recognisable clip for `maintenance`. fn enable_maintenance_clip(app_state: &Arc) { let mut cfg = (**app_state.app_config.config.load()).clone(); cfg.custom_stream_response_enabled = true; app_state.app_config.config.store(Arc::new(cfg)); let mut packet = vec![0xff_u8; 188]; packet[..4].copy_from_slice(&[0x47, 0x01, 0x00, 0x10]); packet[4..9].copy_from_slice(b"MAINT"); app_state.app_config.custom_stream_response.store(Some(Arc::new(crate::model::CustomStreamResponse { channel_unavailable: None, user_connections_exhausted: None, provider_connections_exhausted: None, low_priority_preempted: None, user_account_expired: None, panel_api_provisioning: None, hls_session_or_lease_expired: None, maintenance: Some(crate::api::model::TransportStreamBuffer::new(packet)), panel_api_provisioning_hls_segments: Vec::new(), }))); } /// Gives `user` (limited to one connection) a running stream on another channel. async fn occupy_the_only_connection(app_state: &Arc, user: &ProxyUserCredentials) { let busy_addr = "127.0.0.1:55401".parse().expect("addr"); let busy = Fingerprint::new("fp-busy".to_string(), "127.0.0.1".to_string(), busy_addr); app_state.connection_manager.add_connection(&busy_addr).await; app_state .active_users .create_user_session(crate::api::model::CreateUserSessionParams { user, session_token: "busy-session", virtual_id: 7, provider: "provider-b", stream_url: "http://other.example/live/7.ts", addr: &busy_addr, connection_permission: shared::model::UserConnectionPermission::Allowed, connection_kind: Some(crate::api::model::ConnectionKind::Normal), socket_bound: false, }) .await; let channel = shared::model::StreamChannel { target_id: 1, virtual_id: 7, group_virtual_id: None, provider_id: 7, input_name: "provider-b".intern(), item_type: PlaylistItemType::Live, cluster: XtreamCluster::Live, group: "Live".intern(), title: "Busy".intern(), url: "http://other.example/live/7.ts".into(), shared: false, shared_joined_existing: None, shared_stream_id: None, technical: None, epg_channel_id: None, epg_reference_ts: None, upstream_user_agent: None, }; app_state .connection_manager .update_connection(crate::api::model::ConnectionParams { meter_uid: 401, username: "busy", max_connections: 1, soft_connections: 0, connection_kind: crate::api::model::ConnectionKind::Normal, priority: 0, soft_priority: 0, fingerprint: &busy, provider: "provider-b".intern(), stream_channel: &channel, user_agent: std::borrow::Cow::Borrowed("player/1.0"), session_token: Some("busy-session"), }) .await; } #[tokio::test] async fn maintenance_answers_before_admission_for_an_exhausted_user() { // An exhausted user asking for an input in maintenance gets the maintenance clip, and the // request never reaches admission (no connection-denied event, no eviction). let dir = tempdir().expect("temp dir"); let config = Config { storage_dir: dir.path().to_string_lossy().into_owned(), user_access_control: true, ..Config::default() }; let app_state = create_test_app_state(config); enable_maintenance_clip(&app_state); let target = Arc::new(short_epg_target()); let mut item = short_epg_live_item(); item.provider_id = 100; item.input_name = "provider_a".intern(); item.url = "http://127.0.0.1:1/live/user/pass/100.ts".intern(); let input = ConfigInput { id: 1, name: "provider_a".intern(), input_type: InputType::Xtream, url: "http://127.0.0.1:1".to_string(), username: Some("user".to_string()), password: Some("pass".to_string()), enabled: true, max_connections: 5, ..ConfigInput::default() }; cache_xtream_test_item(&app_state, &target, item, Some(input)).await; app_state.maintenance.add("provider_a", None, 1).await.expect("maintenance"); let mut user = ProxyUserCredentials::default(); user.username = "busy".to_string(); user.password = "busy".to_string(); user.max_connections = 1; user.soft_connections = 0; user.proxy = shared::model::ProxyType::Reverse(None); user.output_clusters = ClusterFlags::all(); occupy_the_only_connection(&app_state, &user).await; let addr = "127.0.0.1:55402".parse().expect("addr"); let fingerprint = Fingerprint::new("fp-new".to_string(), "127.0.0.1".to_string(), addr); let response = xtream_player_api_stream( &fingerprint, &HeaderMap::new(), &app_state, &UserApiRequest::default(), ApiStreamRequest::from(ApiStreamContext::LiveAlt, "busy", "busy", "100.ts", ""), Some((Arc::new(user), target)), ) .await .into_response(); assert_eq!(response.status(), axum::http::StatusCode::OK, "the maintenance clip answers"); let body = tokio::time::timeout(std::time::Duration::from_secs(2), async { let mut body = response.into_body(); let frame = http_body_util::BodyExt::frame(&mut body).await; frame.and_then(Result::ok).and_then(|frame| frame.into_data().ok()).unwrap_or_default() }) .await .unwrap_or_default(); assert!(body.windows(5).any(|window| window == b"MAINT"), "the maintenance clip is served"); let denied = app_state .active_users .events() .stats() .emitted() .into_iter() .find_map(|(kind, count)| (kind == shared::model::EventKind::ConnectionDenied).then_some(count)) .unwrap_or(0); assert_eq!(denied, 0, "the request never reached admission"); }