mirror of
https://github.com/euzu/tuliprox.git
synced 2026-09-25 10:32:15 +02:00
* Fix: setting auth web_ui token_ttl_mins = 0 fixed. * Fix: catchup * New: added playlist update log view * Fix: Landing Page New: "Last Page" added for Landing Page selection
1473 lines
61 KiB
Rust
1473 lines
61 KiB
Rust
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<EpgChannel>)>) -> Vec<EpgChannel> {
|
|
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<AppState>,
|
|
input: &ConfigInput,
|
|
) -> Result<Option<Vec<EpgChannel>>, 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<Arc<AppState>>,
|
|
axum::extract::Json(targets): axum::extract::Json<Vec<String>>,
|
|
) -> 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<String>,
|
|
app_state: &Arc<AppState>,
|
|
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<Arc<AppState>>,
|
|
axum::extract::Json(playlist_req): axum::extract::Json<PlaylistRequest>,
|
|
) -> 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<Arc<AppState>>,
|
|
axum::extract::Json(playlist_req): axum::extract::Json<PlaylistRequest>,
|
|
) -> impl IntoResponse + Send {
|
|
let provider_id = provider_id.trim().parse::<u32>().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<Arc<AppState>>,
|
|
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<Arc<AppState>>,
|
|
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<Arc<AppState>>,
|
|
axum::extract::Json(playlist_epg_req): axum::extract::Json<PlaylistEpgRequest>,
|
|
) -> 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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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<String>,
|
|
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
|
) -> 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<Arc<AppState>>,
|
|
axum::extract::Json(request): axum::extract::Json<PlaylistUrlResolveRequest>,
|
|
) -> 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<Arc<AppState>>) -> axum::Router<Arc<AppState>> {
|
|
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<Arc<AppState>>,
|
|
) -> axum::Router<Arc<AppState>> {
|
|
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<Arc<AppState>>,
|
|
app_state: &Arc<AppState>,
|
|
) -> axum::Router<Arc<AppState>> {
|
|
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<String>,
|
|
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
|
axum::extract::Json(playlist_req): axum::extract::Json<PlaylistRequest>,
|
|
) -> 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::<u32>() {
|
|
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<ConfigInput>, 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<AppConfig>) -> Arc<AppState> {
|
|
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::<GeoIp>::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::<crate::api::model::ManualPlaylistUpdateRequest>(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<_>>(),
|
|
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#"<?xml version="1.0" encoding="utf-8"?>
|
|
<tv>
|
|
<channel id="demo.channel">
|
|
<display-name>Demo Channel</display-name>
|
|
</channel>
|
|
<programme start="{prog_start}" stop="{prog_stop}" channel="demo.channel">
|
|
<title>Morning Show</title>
|
|
</programme>
|
|
</tv>"#
|
|
),
|
|
)
|
|
.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::<Body>().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#"<?xml version="1.0" encoding="utf-8"?>
|
|
<tv>
|
|
<channel id="demo.channel">
|
|
<display-name>Demo Channel</display-name>
|
|
</channel>
|
|
<programme start="{prog_start}" stop="{prog_stop}" channel="demo.channel">
|
|
<title>Morning Show</title>
|
|
</programme>
|
|
</tv>"#
|
|
),
|
|
)
|
|
.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::<Body>().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::<Body>().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#"<?xml version="1.0" encoding="utf-8"?>
|
|
<tv>
|
|
<channel id="demo.channel">
|
|
<display-name>Secondary Channel</display-name>
|
|
<icon src="http://secondary/icon.png" />
|
|
</channel>
|
|
<programme start="{p0}" stop="{p1}" channel="demo.channel">
|
|
<title>Secondary Show</title>
|
|
</programme>
|
|
</tv>"#
|
|
),
|
|
)
|
|
.await
|
|
.expect("write secondary epg");
|
|
tokio::fs::write(
|
|
&primary_path,
|
|
format!(
|
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
|
<tv>
|
|
<channel id="demo.channel">
|
|
<display-name>Primary Channel</display-name>
|
|
<icon src="http://primary/icon.png" />
|
|
</channel>
|
|
<programme start="{p1}" stop="{p2}" channel="demo.channel">
|
|
<title>Primary Show</title>
|
|
</programme>
|
|
</tv>"#
|
|
),
|
|
)
|
|
.await
|
|
.expect("write primary epg");
|
|
tokio::fs::write(
|
|
&same_priority_path,
|
|
format!(
|
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
|
<tv>
|
|
<channel id="demo.channel">
|
|
<display-name>Same Priority Channel</display-name>
|
|
<icon src="http://same/icon.png" />
|
|
</channel>
|
|
<programme start="{p0}" stop="{p1}" channel="demo.channel">
|
|
<title>Duplicate Show</title>
|
|
</programme>
|
|
<programme start="{p2}" stop="{p3}" channel="demo.channel">
|
|
<title>Second Show</title>
|
|
</programme>
|
|
</tv>"#
|
|
),
|
|
)
|
|
.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::<Body>().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}");
|
|
}
|
|
|
|
}
|