mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-17 13:22:19 +02:00
1021 lines
38 KiB
Rust
1021 lines
38 KiB
Rust
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<String, String> {
|
|
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<str>, Arc<str>>::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<AppState>,
|
|
target: &Arc<ConfigTarget>,
|
|
item: XtreamPlaylistItem,
|
|
input: Option<ConfigInput>,
|
|
) {
|
|
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<ConfigInput>,
|
|
) -> (Arc<AppState>, Arc<ConfigTarget>) {
|
|
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<str>, Arc<str>>::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::<str>::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::<str>::from("media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted%2Ffile.mkv");
|
|
|
|
let resolved = get_xtream_player_api_stream_url(&input, ApiStreamContext::Movie, "813563.mkv", &fallback)
|
|
.expect("media-server fallback should be preserved");
|
|
|
|
assert_eq!(resolved, fallback);
|
|
}
|
|
|
|
#[test]
|
|
fn live_playback_extension_keeps_client_requested_extension() {
|
|
let mut pli = create_test_vod_item("provider://strong/live/user/pass/813563.ts", "ts", PlaylistItemType::Live);
|
|
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<str> = 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<AppState>) {
|
|
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<AppState>, 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,
|
|
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");
|
|
}
|