mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-29 02:02:23 +01:00
@@ -8,6 +8,10 @@
|
||||
- Series name empty paren problem fixed.
|
||||
- Default sort removed
|
||||
- Reconnect on WebSocket disconnect, WebSocket connection status icon in WEB-UI
|
||||
- Added a Playlist EPG view with timeline, channels, active “now” line, and program details.
|
||||
- New sidebar entry and icon for quick access to EPG.
|
||||
- EPG data can be fetched from selected targets.
|
||||
|
||||
|
||||
# 3.1.6 (2025-09-01)
|
||||
- EPG Config View added
|
||||
|
||||
@@ -8,4 +8,7 @@ pub(in crate::api) mod hls_api;
|
||||
mod user_api;
|
||||
pub(in crate::api) mod hdhomerun_api;
|
||||
mod api_playlist_utils;
|
||||
pub (in crate::api) mod websocket_api;
|
||||
pub (in crate::api) mod websocket_api;
|
||||
mod v1_api_playlist;
|
||||
mod v1_api_user;
|
||||
mod v1_api_config;
|
||||
@@ -1,369 +1,18 @@
|
||||
use crate::api::endpoints::api_playlist_utils::{get_playlist, get_playlist_for_target};
|
||||
use crate::api::api_utils::try_unwrap_body;
|
||||
use crate::api::endpoints::download_api;
|
||||
use crate::api::endpoints::user_api::user_api_register;
|
||||
use crate::api::endpoints::v1_api_playlist::v1_api_playlist_register;
|
||||
use crate::api::endpoints::v1_api_user::v1_api_user_register;
|
||||
use crate::api::model::AppState;
|
||||
use crate::auth::create_access_token;
|
||||
use crate::auth::validator_admin;
|
||||
use crate::model::{InputSource, ProxyUserCredentials};
|
||||
use crate::model::{ConfigInput, ConfigInputOptions};
|
||||
use crate::processing::processor::playlist;
|
||||
use crate::repository::user_repository::store_api_user;
|
||||
use crate::utils::ip_checker::get_ips;
|
||||
use crate::{utils, VERSION};
|
||||
use crate::api::api_utils::try_unwrap_body;
|
||||
use crate::{VERSION};
|
||||
use axum::response::IntoResponse;
|
||||
use log::error;
|
||||
use serde_json::json;
|
||||
use shared::error::TuliproxError;
|
||||
use shared::model::{ApiProxyConfigDto, ApiProxyServerInfoDto, ConfigDto, InputType, IpCheckDto, PlaylistRequest, PlaylistRequestType, ProxyUserCredentialsDto, StatusCheck, WebplayerUrlRequest};
|
||||
use shared::utils::{concat_path_leading_slash, sanitize_sensitive_info};
|
||||
use std::collections::{BTreeMap};
|
||||
use shared::model::{IpCheckDto,StatusCheck};
|
||||
use shared::utils::{concat_path_leading_slash};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use crate::utils::{prepare_sources_batch, prepare_users};
|
||||
use crate::utils::request::download_text_content;
|
||||
|
||||
fn intern_save_config_api_proxy(backup_dir: &str, api_proxy: &ApiProxyConfigDto, file_path: &str) -> Option<TuliproxError> {
|
||||
match utils::save_api_proxy(file_path, backup_dir, api_proxy) {
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
error!("Failed to save api_proxy.yml {err}");
|
||||
return Some(err);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn intern_save_config_main(file_path: &str, backup_dir: &str, cfg: &ConfigDto) -> Option<TuliproxError> {
|
||||
match utils::save_main_config(file_path, backup_dir, cfg) {
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
error!("Failed to save config.yml {err}");
|
||||
return Some(err);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn save_config_api_proxy_user(
|
||||
method: axum::http::Method,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Path(target_name): axum::extract::Path<String>,
|
||||
axum::extract::Json(mut credential): axum::extract::Json<ProxyUserCredentialsDto>,
|
||||
) -> impl axum::response::IntoResponse + Send {
|
||||
credential.prepare();
|
||||
if let Err(err) = credential.validate() {
|
||||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
|
||||
let is_update = method == axum::http::Method::PUT;
|
||||
|
||||
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
|
||||
let mut api_proxy = (*old_api_proxy).clone();
|
||||
let mut target_found = false;
|
||||
for target_user in &api_proxy.user {
|
||||
if target_user.target == target_name {
|
||||
target_found = true;
|
||||
}
|
||||
for user in &target_user.credentials {
|
||||
if !is_update && user.username == credential.username {
|
||||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("Duplicate username {}", &credential.username)}))).into_response();
|
||||
}
|
||||
if let (Some(u), Some(c)) = (&user.token, &credential.token) {
|
||||
if u == c && user.username != credential.username {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
axum::Json(json!({"error": format!("Duplicate token {c}")}))
|
||||
).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_update && !target_found {
|
||||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("Target not found {target_name}")}))).into_response();
|
||||
}
|
||||
|
||||
for target in &mut api_proxy.user {
|
||||
if target.target == target_name {
|
||||
if is_update {
|
||||
let mut updated = false;
|
||||
for user in &mut target.credentials {
|
||||
if user.username == credential.username {
|
||||
*user = ProxyUserCredentials::from(&credential);
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !updated {
|
||||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("User {} not found in target {target_name}", credential.username)}))).into_response();
|
||||
}
|
||||
} else {
|
||||
target.credentials.push(ProxyUserCredentials::from(&credential));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let new_api_proxy = Arc::new(api_proxy);
|
||||
|
||||
if new_api_proxy.use_user_db {
|
||||
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user) {
|
||||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
} else {
|
||||
let config = app_state.app_config.config.load();
|
||||
let backup_dir = config.get_backup_dir();
|
||||
let paths = app_state.app_config.paths.load();
|
||||
if let Some(err) = intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(&*new_api_proxy), paths.api_proxy_file_path.as_str()) {
|
||||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
}
|
||||
// Udate state after successful save
|
||||
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
|
||||
}
|
||||
axum::http::StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
async fn delete_config_api_proxy_user(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Path((target_name, username)): axum::extract::Path<(String, String)>,
|
||||
) -> impl axum::response::IntoResponse + Send {
|
||||
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
|
||||
let mut api_proxy = (*old_api_proxy).clone();
|
||||
let mut modified = false;
|
||||
|
||||
for target_user in &mut api_proxy.user {
|
||||
if target_user.target == target_name {
|
||||
let count = target_user.credentials.len();
|
||||
target_user.credentials.retain(|user| user.username != username);
|
||||
modified = count != target_user.credentials.len();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if modified {
|
||||
let new_api_proxy = Arc::new(api_proxy);
|
||||
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
|
||||
|
||||
if new_api_proxy.use_user_db {
|
||||
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user) {
|
||||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
} else {
|
||||
let config = app_state.app_config.config.load();
|
||||
let backup_dir = config.get_backup_dir();
|
||||
let paths = app_state.app_config.paths.load();
|
||||
if let Some(err) = intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(&*new_api_proxy), paths.api_proxy_file_path.as_str()) {
|
||||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("User not found {username} in target {target_name}")}))).into_response();
|
||||
}
|
||||
}
|
||||
axum::http::StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
async fn save_config_main(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Json(cfg): axum::extract::Json<ConfigDto>,
|
||||
) -> impl axum::response::IntoResponse + Send {
|
||||
if cfg.is_valid() {
|
||||
let paths = app_state.app_config.paths.load();
|
||||
let file_path = paths.config_file_path.as_str();
|
||||
let config = app_state.app_config.config.load();
|
||||
let backup_dir = config.get_backup_dir();
|
||||
if let Some(err) = intern_save_config_main(file_path, backup_dir.as_ref(), &cfg) {
|
||||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
axum::http::StatusCode::OK.into_response()
|
||||
} else {
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"}))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_config_api_proxy_config(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Json(mut req_api_proxy): axum::extract::Json<Vec<ApiProxyServerInfoDto>>,
|
||||
) -> impl axum::response::IntoResponse + Send {
|
||||
for server_info in &mut req_api_proxy {
|
||||
if !server_info.validate() {
|
||||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"}))).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO if hot reload is on, loaded twice
|
||||
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
|
||||
let mut api_proxy = (*old_api_proxy).clone();
|
||||
api_proxy.server = req_api_proxy.iter().map(Into::into).collect();
|
||||
let new_api_proxy = Arc::new(api_proxy);
|
||||
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
|
||||
let config = app_state.app_config.config.load();
|
||||
let backup_dir = config.get_backup_dir();
|
||||
let paths = app_state.app_config.paths.load();
|
||||
if let Some(err) = intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(new_api_proxy.as_ref()), paths.api_proxy_file_path.as_str()) {
|
||||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
}
|
||||
axum::http::StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
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 app_config = Arc::clone(&app_state.app_config);
|
||||
let event_manager = Arc::clone(&app_state.event_manager);
|
||||
tokio::spawn(playlist::exec_processing(Arc::clone(&app_state.http_client.load()), app_config, Arc::new(valid_targets), Some(event_manager)));
|
||||
axum::http::StatusCode::OK.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed playlist update {}", sanitize_sensitive_info(err.to_string().as_str()));
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_config_input_for_m3u(url: &str) -> ConfigInput {
|
||||
ConfigInput {
|
||||
id: 0,
|
||||
name: String::from("m3u_req"),
|
||||
input_type: InputType::M3u,
|
||||
url: String::from(url),
|
||||
enabled: true,
|
||||
options: Some(ConfigInputOptions {
|
||||
xtream_skip_live: false,
|
||||
xtream_skip_vod: false,
|
||||
xtream_skip_series: false,
|
||||
xtream_live_stream_without_extension: false,
|
||||
xtream_live_stream_use_prefix: true,
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn create_config_input_for_xtream(username: &str, password: &str, host: &str) -> ConfigInput {
|
||||
ConfigInput {
|
||||
id: 0,
|
||||
name: String::from("xc_req"),
|
||||
input_type: InputType::Xtream,
|
||||
url: String::from(host),
|
||||
username: Some(String::from(username)),
|
||||
password: Some(String::from(password)),
|
||||
enabled: true,
|
||||
options: Some(ConfigInputOptions {
|
||||
xtream_skip_live: false,
|
||||
xtream_skip_vod: false,
|
||||
xtream_skip_series: false,
|
||||
xtream_live_stream_without_extension: false,
|
||||
xtream_live_stream_use_prefix: true,
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn playlist_content(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Json(playlist_req): axum::extract::Json<PlaylistRequest>,
|
||||
) -> impl IntoResponse + Send {
|
||||
let config = app_state.app_config.config.load();
|
||||
match playlist_req.rtype {
|
||||
PlaylistRequestType::Input => {
|
||||
if let Some(source_id) = playlist_req.source_id {
|
||||
get_playlist(Arc::clone(&app_state.http_client.load()), app_state.app_config.get_input_by_id(source_id).as_deref(), &config).await.into_response()
|
||||
} else {
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid input"}))).into_response()
|
||||
}
|
||||
}
|
||||
PlaylistRequestType::Target => {
|
||||
if let Some(source_id) = playlist_req.source_id {
|
||||
get_playlist_for_target(app_state.app_config.get_target_by_id(source_id).as_deref(), &app_state.app_config).await.into_response()
|
||||
} else {
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid target"}))).into_response()
|
||||
}
|
||||
}
|
||||
PlaylistRequestType::Xtream => {
|
||||
if let (Some(url), Some(username), Some(password)) = (playlist_req.url.as_ref(), playlist_req.username.as_ref(), playlist_req.password.as_ref()) {
|
||||
let input = create_config_input_for_xtream(username, password, url);
|
||||
get_playlist(Arc::clone(&app_state.http_client.load()), Some(&input), &config).await.into_response()
|
||||
} else {
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url"}))).into_response()
|
||||
}
|
||||
}
|
||||
PlaylistRequestType::M3U => {
|
||||
if let Some(url) = playlist_req.url.as_ref() {
|
||||
let input = create_config_input_for_m3u(url);
|
||||
get_playlist(Arc::clone(&app_state.http_client.load()), Some(&input), &config).await.into_response()
|
||||
} else {
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url"}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn playlist_webplayer(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Json(playlist_item): axum::extract::Json<WebplayerUrlRequest>,
|
||||
) -> 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 base_url = server_info.get_base_url();
|
||||
format!("{base_url}/token/{access_token}/{}/{}/{}", playlist_item.target_id, playlist_item.cluster.as_stream_type(), playlist_item.virtual_id).into_response()
|
||||
}
|
||||
|
||||
async fn config(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
) -> impl axum::response::IntoResponse + Send {
|
||||
let paths = app_state.app_config.paths.load();
|
||||
match utils::read_app_config_dto(&paths, true, false) {
|
||||
Ok(mut app_config) => {
|
||||
if let Err(err) = prepare_sources_batch(&mut app_config.sources, false) {
|
||||
error!("Failed to prepare sources batch: {err}");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
} else if let Err(err) = prepare_users(&mut app_config, &app_state.app_config) {
|
||||
error!("Failed to prepare users: {err}");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
} else {
|
||||
axum::response::Json(app_config).into_response()
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to read config files: {err}");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn config_batch_content(
|
||||
axum::extract::Path(input_id): axum::extract::Path<u16>,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
) -> impl axum::response::IntoResponse + Send {
|
||||
if let Some(config_input) = app_state.app_config.get_input_by_id(input_id) {
|
||||
// The url is changed at this point, we need the raw url for the batch file
|
||||
if let Some(batch_url) = config_input.t_batch_url.as_ref() {
|
||||
let input_source = InputSource::from(&*config_input).with_url(batch_url.to_owned());
|
||||
return match download_text_content(Arc::clone(&app_state.http_client.load()), &input_source, None).await {
|
||||
Ok((content, _path)) => {
|
||||
// Return CSV with explicit content-type
|
||||
try_unwrap_body!(axum::response::Response::builder()
|
||||
.status(axum::http::StatusCode::OK)
|
||||
.header(axum::http::header::CONTENT_TYPE, "text/csv; charset=utf-8")
|
||||
.body(content))
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to read batch file: {err}");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
(axum::http::StatusCode::NOT_FOUND, axum::Json(json!({"error": "Input not found or batch URL missing"}))).into_response()
|
||||
}
|
||||
|
||||
use crate::api::endpoints::v1_api_config::v1_api_config_register;
|
||||
|
||||
async fn create_ipinfo_check(app_state: &Arc<AppState>) -> Option<(Option<String>, Option<String>)> {
|
||||
let config = app_state.app_config.config.load();
|
||||
@@ -429,19 +78,12 @@ pub fn v1_api_register(web_auth_enabled: bool, app_state: Arc<AppState>, web_ui_
|
||||
let mut router = axum::Router::new();
|
||||
router = router
|
||||
.route("/status", axum::routing::get(status))
|
||||
.route("/config", axum::routing::get(config))
|
||||
.route("/config/batchContent/{input_id}", axum::routing::get(config_batch_content))
|
||||
.route("/config/main", axum::routing::post(save_config_main))
|
||||
.route("/user/{target}", axum::routing::post(save_config_api_proxy_user))
|
||||
.route("/user/{target}", axum::routing::put(save_config_api_proxy_user))
|
||||
.route("/user/{target}/{username}", axum::routing::delete(delete_config_api_proxy_user))
|
||||
.route("/config/apiproxy", axum::routing::post(save_config_api_proxy_config))
|
||||
.route("/playlist/webplayer", axum::routing::post(playlist_webplayer))
|
||||
.route("/playlist/update", axum::routing::post(playlist_update))
|
||||
.route("/playlist", axum::routing::post(playlist_content))
|
||||
.route("/file/download", axum::routing::post(download_api::queue_download_file))
|
||||
.route("/file/download/info", axum::routing::get(download_api::download_file_info))
|
||||
.route("/ipinfo", axum::routing::get(ipinfo));
|
||||
router = v1_api_config_register(router);
|
||||
router = v1_api_user_register(router);
|
||||
router = v1_api_playlist_register(router);
|
||||
if web_auth_enabled {
|
||||
router = router.route_layer(axum::middleware::from_fn_with_state(Arc::clone(&app_state), validator_admin));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
use crate::api::model::AppState;
|
||||
use crate::model::{InputSource};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Router;
|
||||
use serde_json::json;
|
||||
use shared::model::{ApiProxyConfigDto, ApiProxyServerInfoDto, ConfigDto};
|
||||
use std::sync::Arc;
|
||||
use log::error;
|
||||
use shared::error::TuliproxError;
|
||||
use crate::api::api_utils::try_unwrap_body;
|
||||
use crate::{utils};
|
||||
use crate::utils::{prepare_sources_batch, prepare_users};
|
||||
use crate::utils::request::download_text_content;
|
||||
|
||||
pub(in crate::api::endpoints) fn intern_save_config_api_proxy(backup_dir: &str, api_proxy: &ApiProxyConfigDto, file_path: &str) -> Option<TuliproxError> {
|
||||
match utils::save_api_proxy(file_path, backup_dir, api_proxy) {
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
error!("Failed to save api_proxy.yml {err}");
|
||||
return Some(err);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn intern_save_config_main(file_path: &str, backup_dir: &str, cfg: &ConfigDto) -> Option<TuliproxError> {
|
||||
match utils::save_main_config(file_path, backup_dir, cfg) {
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
error!("Failed to save config.yml {err}");
|
||||
return Some(err);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn save_config_main(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Json(cfg): axum::extract::Json<ConfigDto>,
|
||||
) -> impl axum::response::IntoResponse + Send {
|
||||
if cfg.is_valid() {
|
||||
let paths = app_state.app_config.paths.load();
|
||||
let file_path = paths.config_file_path.as_str();
|
||||
let config = app_state.app_config.config.load();
|
||||
let backup_dir = config.get_backup_dir();
|
||||
if let Some(err) = intern_save_config_main(file_path, backup_dir.as_ref(), &cfg) {
|
||||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
axum::http::StatusCode::OK.into_response()
|
||||
} else {
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"}))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_config_api_proxy_config(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Json(mut req_api_proxy): axum::extract::Json<Vec<ApiProxyServerInfoDto>>,
|
||||
) -> impl axum::response::IntoResponse + Send {
|
||||
for server_info in &mut req_api_proxy {
|
||||
if !server_info.validate() {
|
||||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"}))).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO if hot reload is on, loaded twice
|
||||
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
|
||||
let mut api_proxy = (*old_api_proxy).clone();
|
||||
api_proxy.server = req_api_proxy.iter().map(Into::into).collect();
|
||||
let new_api_proxy = Arc::new(api_proxy);
|
||||
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
|
||||
let config = app_state.app_config.config.load();
|
||||
let backup_dir = config.get_backup_dir();
|
||||
let paths = app_state.app_config.paths.load();
|
||||
if let Some(err) = intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(new_api_proxy.as_ref()), paths.api_proxy_file_path.as_str()) {
|
||||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
}
|
||||
axum::http::StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
async fn config(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
) -> impl axum::response::IntoResponse + Send {
|
||||
let paths = app_state.app_config.paths.load();
|
||||
match utils::read_app_config_dto(&paths, true, false) {
|
||||
Ok(mut app_config) => {
|
||||
if let Err(err) = prepare_sources_batch(&mut app_config.sources, false) {
|
||||
error!("Failed to prepare sources batch: {err}");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
} else if let Err(err) = prepare_users(&mut app_config, &app_state.app_config) {
|
||||
error!("Failed to prepare users: {err}");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
} else {
|
||||
axum::response::Json(app_config).into_response()
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to read config files: {err}");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn config_batch_content(
|
||||
axum::extract::Path(input_id): axum::extract::Path<u16>,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
) -> impl axum::response::IntoResponse + Send {
|
||||
if let Some(config_input) = app_state.app_config.get_input_by_id(input_id) {
|
||||
// The url is changed at this point, we need the raw url for the batch file
|
||||
if let Some(batch_url) = config_input.t_batch_url.as_ref() {
|
||||
let input_source = InputSource::from(&*config_input).with_url(batch_url.to_owned());
|
||||
return match download_text_content(Arc::clone(&app_state.http_client.load()), &input_source, None).await {
|
||||
Ok((content, _path)) => {
|
||||
// Return CSV with explicit content-type
|
||||
try_unwrap_body!(axum::response::Response::builder()
|
||||
.status(axum::http::StatusCode::OK)
|
||||
.header(axum::http::header::CONTENT_TYPE, "text/csv; charset=utf-8")
|
||||
.body(content))
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to read batch file: {err}");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
(axum::http::StatusCode::NOT_FOUND, axum::Json(json!({"error": "Input not found or batch URL missing"}))).into_response()
|
||||
}
|
||||
|
||||
|
||||
pub fn v1_api_config_register(router: Router<Arc<AppState>>) -> axum::Router<Arc<AppState>> {
|
||||
router
|
||||
.route("/config", axum::routing::get(config))
|
||||
.route("/config/batchContent/{input_id}", axum::routing::get(config_batch_content))
|
||||
.route("/config/main", axum::routing::post(save_config_main))
|
||||
.route("/config/apiproxy", axum::routing::post(save_config_api_proxy_config))
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use std::sync::Arc;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Router;
|
||||
use log::error;
|
||||
use serde_json::json;
|
||||
use shared::model::{InputType, PlaylistEpgRequest, PlaylistRequest, PlaylistRequestType, WebplayerUrlRequest};
|
||||
use shared::utils::sanitize_sensitive_info;
|
||||
use crate::api::endpoints::api_playlist_utils::{get_playlist, get_playlist_for_target};
|
||||
use crate::api::model::AppState;
|
||||
use crate::auth::create_access_token;
|
||||
use crate::model::{parse_xmltv_for_web_ui, ConfigInput, ConfigInputOptions};
|
||||
use crate::processing::processor::playlist;
|
||||
use url::Url;
|
||||
|
||||
fn create_config_input_for_m3u(url: &str) -> ConfigInput {
|
||||
ConfigInput {
|
||||
id: 0,
|
||||
name: String::from("m3u_req"),
|
||||
input_type: InputType::M3u,
|
||||
url: String::from(url),
|
||||
enabled: true,
|
||||
options: Some(ConfigInputOptions {
|
||||
xtream_skip_live: false,
|
||||
xtream_skip_vod: false,
|
||||
xtream_skip_series: false,
|
||||
xtream_live_stream_without_extension: false,
|
||||
xtream_live_stream_use_prefix: true,
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn create_config_input_for_xtream(username: &str, password: &str, host: &str) -> ConfigInput {
|
||||
ConfigInput {
|
||||
id: 0,
|
||||
name: String::from("xc_req"),
|
||||
input_type: InputType::Xtream,
|
||||
url: String::from(host),
|
||||
username: Some(String::from(username)),
|
||||
password: Some(String::from(password)),
|
||||
enabled: true,
|
||||
options: Some(ConfigInputOptions {
|
||||
xtream_skip_live: false,
|
||||
xtream_skip_vod: false,
|
||||
xtream_skip_series: false,
|
||||
xtream_live_stream_without_extension: false,
|
||||
xtream_live_stream_use_prefix: true,
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
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 app_config = Arc::clone(&app_state.app_config);
|
||||
let event_manager = Arc::clone(&app_state.event_manager);
|
||||
tokio::spawn(playlist::exec_processing(Arc::clone(&app_state.http_client.load()), app_config, Arc::new(valid_targets), Some(event_manager)));
|
||||
axum::http::StatusCode::OK.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed playlist update {}", sanitize_sensitive_info(err.to_string().as_str()));
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async fn playlist_content(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Json(playlist_req): axum::extract::Json<PlaylistRequest>,
|
||||
) -> impl IntoResponse + Send {
|
||||
let config = app_state.app_config.config.load();
|
||||
match playlist_req.rtype {
|
||||
PlaylistRequestType::Input => {
|
||||
if let Some(source_id) = playlist_req.source_id {
|
||||
get_playlist(Arc::clone(&app_state.http_client.load()), app_state.app_config.get_input_by_id(source_id).as_deref(), &config).await.into_response()
|
||||
} else {
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid input"}))).into_response()
|
||||
}
|
||||
}
|
||||
PlaylistRequestType::Target => {
|
||||
if let Some(source_id) = playlist_req.source_id {
|
||||
get_playlist_for_target(app_state.app_config.get_target_by_id(source_id).as_deref(), &app_state.app_config).await.into_response()
|
||||
} else {
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid target"}))).into_response()
|
||||
}
|
||||
}
|
||||
PlaylistRequestType::Xtream => {
|
||||
if let (Some(url), Some(username), Some(password)) = (playlist_req.url.as_ref(), playlist_req.username.as_ref(), playlist_req.password.as_ref()) {
|
||||
match Url::parse(url) {
|
||||
Ok(parsed) if parsed.scheme() == "http" || parsed.scheme() == "https" => {
|
||||
let input = create_config_input_for_xtream(username, password, url);
|
||||
get_playlist(Arc::clone(&app_state.http_client.load()), Some(&input), &config).await.into_response()
|
||||
}
|
||||
_ => {
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url scheme; only http/https are allowed"}))).into_response()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url"}))).into_response()
|
||||
}
|
||||
}
|
||||
PlaylistRequestType::M3U => {
|
||||
if let Some(url) = playlist_req.url.as_ref() {
|
||||
match Url::parse(url) {
|
||||
Ok(parsed) if parsed.scheme() == "http" || parsed.scheme() == "https" => {
|
||||
let input = create_config_input_for_m3u(url);
|
||||
get_playlist(Arc::clone(&app_state.http_client.load()), Some(&input), &config).await.into_response()
|
||||
}
|
||||
_ => {
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url scheme; only http/https are allowed"}))).into_response()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url"}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn playlist_webplayer(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Json(playlist_item): axum::extract::Json<WebplayerUrlRequest>,
|
||||
) -> 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 base_url = server_info.get_base_url();
|
||||
format!("{base_url}/token/{access_token}/{}/{}/{}", playlist_item.target_id, playlist_item.cluster.as_stream_type(), playlist_item.virtual_id).into_response()
|
||||
}
|
||||
|
||||
async fn playlist_epg(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Json(playlist_epg_req): axum::extract::Json<PlaylistEpgRequest>,
|
||||
) -> impl IntoResponse + Send {
|
||||
if let Some(target) = app_state.app_config.get_target_by_id(playlist_epg_req.target_id) {
|
||||
let config = &app_state.app_config.config.load();
|
||||
if let Some(epg_path) = crate::api::endpoints::xmltv_api::get_epg_path_for_target(config, &target) {
|
||||
if let Ok(epg) = parse_xmltv_for_web_ui(&epg_path) {
|
||||
return (axum::http::StatusCode::OK, axum::Json(epg)).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
axum::http::StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
pub fn v1_api_playlist_register(router: Router<Arc<AppState>>) -> axum::Router<Arc<AppState>> {
|
||||
router
|
||||
.route("/playlist/webplayer", axum::routing::post(playlist_webplayer))
|
||||
.route("/playlist/update", axum::routing::post(playlist_update))
|
||||
.route("/playlist/epg", axum::routing::post(playlist_epg))
|
||||
.route("/playlist", axum::routing::post(playlist_content))
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use crate::api::model::AppState;
|
||||
use crate::model::ProxyUserCredentials;
|
||||
use crate::repository::user_repository::store_api_user;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Router;
|
||||
use serde_json::json;
|
||||
use shared::model::{ApiProxyConfigDto, ProxyUserCredentialsDto};
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn save_config_api_proxy_user(
|
||||
method: axum::http::Method,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Path(target_name): axum::extract::Path<String>,
|
||||
axum::extract::Json(mut credential): axum::extract::Json<ProxyUserCredentialsDto>,
|
||||
) -> impl axum::response::IntoResponse + Send {
|
||||
credential.prepare();
|
||||
if let Err(err) = credential.validate() {
|
||||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
|
||||
let is_update = method == axum::http::Method::PUT;
|
||||
|
||||
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
|
||||
let mut api_proxy = (*old_api_proxy).clone();
|
||||
let mut target_found = false;
|
||||
for target_user in &api_proxy.user {
|
||||
if target_user.target == target_name {
|
||||
target_found = true;
|
||||
}
|
||||
for user in &target_user.credentials {
|
||||
if !is_update && user.username == credential.username {
|
||||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("Duplicate username {}", &credential.username)}))).into_response();
|
||||
}
|
||||
if let (Some(u), Some(c)) = (&user.token, &credential.token) {
|
||||
if u == c && user.username != credential.username {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
axum::Json(json!({"error": format!("Duplicate token {c}")}))
|
||||
).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_update && !target_found {
|
||||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("Target not found {target_name}")}))).into_response();
|
||||
}
|
||||
|
||||
for target in &mut api_proxy.user {
|
||||
if target.target == target_name {
|
||||
if is_update {
|
||||
let mut updated = false;
|
||||
for user in &mut target.credentials {
|
||||
if user.username == credential.username {
|
||||
*user = ProxyUserCredentials::from(&credential);
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !updated {
|
||||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("User {} not found in target {target_name}", credential.username)}))).into_response();
|
||||
}
|
||||
} else {
|
||||
target.credentials.push(ProxyUserCredentials::from(&credential));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let new_api_proxy = Arc::new(api_proxy);
|
||||
|
||||
if new_api_proxy.use_user_db {
|
||||
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user) {
|
||||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
} else {
|
||||
let config = app_state.app_config.config.load();
|
||||
let backup_dir = config.get_backup_dir();
|
||||
let paths = app_state.app_config.paths.load();
|
||||
if let Some(err) = crate::api::endpoints::v1_api_config::intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(&*new_api_proxy), paths.api_proxy_file_path.as_str()) {
|
||||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
}
|
||||
// Udate state after successful save
|
||||
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
|
||||
}
|
||||
axum::http::StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
async fn delete_config_api_proxy_user(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Path((target_name, username)): axum::extract::Path<(String, String)>,
|
||||
) -> impl axum::response::IntoResponse + Send {
|
||||
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
|
||||
let mut api_proxy = (*old_api_proxy).clone();
|
||||
let mut modified = false;
|
||||
|
||||
for target_user in &mut api_proxy.user {
|
||||
if target_user.target == target_name {
|
||||
let count = target_user.credentials.len();
|
||||
target_user.credentials.retain(|user| user.username != username);
|
||||
modified = count != target_user.credentials.len();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if modified {
|
||||
let new_api_proxy = Arc::new(api_proxy);
|
||||
if new_api_proxy.use_user_db {
|
||||
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user) {
|
||||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
} else {
|
||||
let config = app_state.app_config.config.load();
|
||||
let backup_dir = config.get_backup_dir();
|
||||
let paths = app_state.app_config.paths.load();
|
||||
if let Some(err) = crate::api::endpoints::v1_api_config::intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(&*new_api_proxy), paths.api_proxy_file_path.as_str()) {
|
||||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
}
|
||||
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
|
||||
} else {
|
||||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("User not found {username} in target {target_name}")}))).into_response();
|
||||
}
|
||||
}
|
||||
axum::http::StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
pub fn v1_api_user_register(router: Router<Arc<AppState>>) -> axum::Router<Arc<AppState>> {
|
||||
router
|
||||
.route("/user/{target}", axum::routing::post(save_config_api_proxy_user))
|
||||
.route("/user/{target}", axum::routing::put(save_config_api_proxy_user))
|
||||
.route("/user/{target}/{username}", axum::routing::delete(delete_config_api_proxy_user))
|
||||
}
|
||||
@@ -58,7 +58,7 @@ fn get_epg_path_for_target_of_type(target_name: &str, epg_path: PathBuf) -> Opti
|
||||
None
|
||||
}
|
||||
|
||||
fn get_epg_path_for_target(config: &Config, target: &ConfigTarget) -> Option<PathBuf> {
|
||||
pub (in crate::api) fn get_epg_path_for_target(config: &Config, target: &ConfigTarget) -> Option<PathBuf> {
|
||||
// TODO if we have multiple targets, first one serves, this can be problematic when
|
||||
// we use m3u playlist but serve xtream target epg
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ impl AppConfig {
|
||||
None => None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn get_inputs_for_target(&self, target_name: &str) -> Option<Vec<Arc<ConfigInput>>> {
|
||||
let sources = <Arc<ArcSwap<SourcesConfig>> as Access<SourcesConfig>>::load(&self.sources);
|
||||
for source in &sources.sources {
|
||||
|
||||
@@ -17,4 +17,4 @@ pub use self::healthcheck::*;
|
||||
pub use self::playlist_categories::*;
|
||||
pub use shared::model::xtream_const::*;
|
||||
pub use self::config::*;
|
||||
pub use self::input_source::*;
|
||||
pub use self::input_source::*;
|
||||
|
||||
+184
-6
@@ -1,8 +1,14 @@
|
||||
use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
|
||||
use quick_xml::{Error, Writer};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use crate::model::xmltv::XmlTagIcon::Undefined;
|
||||
use chrono::{Datelike, TimeZone, Utc};
|
||||
use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
|
||||
use quick_xml::{Error, Reader, Writer};
|
||||
use shared::error::{TuliproxError, TuliproxErrorKind};
|
||||
use shared::model::{parse_xmltv_time, EpgChannel, EpgProgramme, EpgTv};
|
||||
use std::cmp::{max, min};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const EPG_TAG_TV: &str = "tv";
|
||||
pub const EPG_TAG_PROGRAMME: &str = "programme";
|
||||
@@ -15,7 +21,7 @@ pub const EPG_TAG_ICON: &str = "icon";
|
||||
// https://github.com/XMLTV/xmltv/blob/master/xmltv.dtd
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq,Default)]
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Default)]
|
||||
pub enum XmlTagIcon {
|
||||
#[default]
|
||||
Undefined,
|
||||
@@ -54,7 +60,7 @@ impl XmlTag {
|
||||
|
||||
// empty icon not processed
|
||||
if self.icon == Undefined && self.name.eq(EPG_TAG_ICON) {
|
||||
return Ok(());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(attribs) = self.attributes.as_ref() {
|
||||
@@ -119,3 +125,175 @@ impl TVGuide {
|
||||
&self.epg_sources
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn filter_channels_and_programmes(
|
||||
channels: &mut Vec<EpgChannel>,
|
||||
programmes: &mut Vec<EpgProgramme>,
|
||||
) {
|
||||
for channel in channels.iter_mut() {
|
||||
let mut i = 0;
|
||||
while i < programmes.len() {
|
||||
if programmes[i].channel == channel.id {
|
||||
let prog = programmes.swap_remove(i);
|
||||
channel.programmes.push(prog);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
channel.programmes.sort_by_key(|p| p.start);
|
||||
}
|
||||
|
||||
channels.retain(|c| !c.programmes.is_empty());
|
||||
}
|
||||
|
||||
fn get_epg_interval(channels: &Vec<EpgChannel>) -> (i64, i64) {
|
||||
let mut epg_start = i64::MAX;
|
||||
let mut epg_stop = i64::MIN;
|
||||
for channel in channels {
|
||||
for programme in &channel.programmes {
|
||||
epg_start = min(epg_start, programme.start);
|
||||
epg_stop = max(epg_stop, programme.stop);
|
||||
}
|
||||
}
|
||||
(epg_start, epg_stop)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn parse_xmltv_for_web_ui(path: &Path) -> Result<EpgTv, TuliproxError> {
|
||||
let file = File::open(path).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, err.to_string()))?;
|
||||
let mut reader = Reader::from_reader(BufReader::new(file));
|
||||
|
||||
let mut buf = Vec::new();
|
||||
|
||||
let mut channels = Vec::new();
|
||||
let mut programmes = Vec::new();
|
||||
|
||||
let mut current_channel: Option<EpgChannel> = None;
|
||||
let mut current_programme: Option<EpgProgramme> = None;
|
||||
|
||||
let mut current_tag = String::new();
|
||||
|
||||
// only 1 day old epg
|
||||
let now = Utc::now();
|
||||
let yesterday_start = Utc.with_ymd_and_hms(now.year(), now.month(), now.day(), 0, 0, 0).unwrap()
|
||||
- chrono::Duration::days(1);
|
||||
let threshold_ts = yesterday_start.timestamp();
|
||||
|
||||
let get_attr_value = |attr: &quick_xml::events::attributes::Attribute| {
|
||||
if let Ok(value) = attr.unescape_value() {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(e)) => {
|
||||
let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
||||
current_tag.clone_from(&tag);
|
||||
|
||||
match tag.as_str() {
|
||||
EPG_TAG_CHANNEL => {
|
||||
let mut id = None;
|
||||
for attr in e.attributes().flatten() {
|
||||
if attr.key.as_ref() == b"id" {
|
||||
if let Some(value) = get_attr_value(&attr) {
|
||||
id = Some(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(cid) = id {
|
||||
current_channel = Some(EpgChannel::new(cid));
|
||||
} else {
|
||||
current_channel = None;
|
||||
}
|
||||
}
|
||||
EPG_TAG_PROGRAMME => {
|
||||
let mut start = None;
|
||||
let mut stop = None;
|
||||
let mut channel = None;
|
||||
current_programme = None;
|
||||
for attr in e.attributes().flatten() {
|
||||
match attr.key.as_ref() {
|
||||
b"start" => start = get_attr_value(&attr),
|
||||
b"stop" => stop = get_attr_value(&attr),
|
||||
b"channel" => channel = get_attr_value(&attr),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let (Some(pstart), Some(pstop), Some(pchannel)) = (start, stop, channel) {
|
||||
if let (Some(start_time), Some(stop_time)) = (parse_xmltv_time(&pstart), parse_xmltv_time(&pstop)) {
|
||||
if stop_time >= threshold_ts {
|
||||
let epg_programme = EpgProgramme::new(start_time, stop_time, pchannel);
|
||||
current_programme = Some(epg_programme);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Event::Text(e)) => {
|
||||
if let Ok(decoded) = e.decode() {
|
||||
let text = decoded.trim();
|
||||
if !text.is_empty() {
|
||||
if let Some(channel) = &mut current_channel {
|
||||
if current_tag == EPG_TAG_DISPLAY_NAME && channel.title.is_empty() {
|
||||
channel.title = text.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(program) = &mut current_programme {
|
||||
if current_tag == "title" && program.title.is_empty() {
|
||||
program.title = text.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::End(e)) => {
|
||||
let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
||||
match tag.as_str() {
|
||||
EPG_TAG_CHANNEL => {
|
||||
if let Some(channel) = current_channel.take() {
|
||||
channels.push(channel);
|
||||
}
|
||||
}
|
||||
EPG_TAG_PROGRAMME => {
|
||||
if let Some(program) = current_programme.take() {
|
||||
programmes.push(program);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
current_tag.clear();
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(err) => return Err(TuliproxError::new(TuliproxErrorKind::Info, err.to_string())),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
buf.clear();
|
||||
}
|
||||
|
||||
filter_channels_and_programmes(&mut channels, &mut programmes);
|
||||
|
||||
if channels.is_empty() {
|
||||
return Ok(EpgTv {
|
||||
start: 0,
|
||||
stop: 0,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
|
||||
let (epg_start, epg_stop) = get_epg_interval(&channels);
|
||||
|
||||
Ok(EpgTv {
|
||||
start: epg_start,
|
||||
stop: epg_stop,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"PLAYLIST_VIEWER": "Explore",
|
||||
"PLAYLIST_EXPLORER": "Playlist Browser",
|
||||
"PLAYLIST_UPDATE": "Playlist Update",
|
||||
"PLAYLIST_EPG": "EPG",
|
||||
"SOURCES": "Sources",
|
||||
"SOURCE_PICKER": "Source Picker",
|
||||
"LIST": "List",
|
||||
|
||||
@@ -495,5 +495,9 @@
|
||||
{
|
||||
"keys": ["WsDisconnected", "Offline"],
|
||||
"path": "M12 2.02c-5.51 0-9.98 4.47-9.98 9.98s4.47 9.98 9.98 9.98 9.98-4.47 9.98-9.98S17.51 2.02 12 2.02m0 17.96c-4.4 0-7.98-3.58-7.98-7.98S7.6 4.02 12 4.02 19.98 7.6 19.98 12 16.4 19.98 12 19.98M12.75 5l-4.5 8.5h3.14V19l4.36-8.5h-3z"
|
||||
},
|
||||
{
|
||||
"keys": ["Epg"],
|
||||
"path": "M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2m0 14H3V5h18zm-2-9H8v2h11zm0 4H8v2h11zM7 8H5v2h2zm0 4H5v2h2z"
|
||||
}
|
||||
]
|
||||
@@ -29,6 +29,11 @@
|
||||
--margin-mini: 0.5rem;
|
||||
--margin-small: 0.75rem;
|
||||
--margin-default: 1rem;
|
||||
|
||||
--epg-row-height: 4rem;
|
||||
--epg-header-height: 2rem;
|
||||
--epg-channels-width: 200px;
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: size.$mobile-breakpoint) {
|
||||
@@ -290,6 +295,13 @@
|
||||
--code-null-literal-color: #fa5f2d;
|
||||
|
||||
--toolbar-bg-color: #161b25;
|
||||
|
||||
--epg-program-background-color: #57317a; //#31597a;
|
||||
--epg-program-color: #ffffff;
|
||||
--epg-program-active-background-color: #65a181;
|
||||
--epg-program-active-color: #ffffff;
|
||||
--epg-program-time-color: #acbcdd;
|
||||
--epg-program-active-time-color: #000000;
|
||||
}
|
||||
|
||||
body[data-theme="bright"] {
|
||||
|
||||
@@ -72,3 +72,4 @@
|
||||
@forward "components/keyvalue_editor";
|
||||
@forward "components/loading_screen";
|
||||
@forward "components/websocket_status";
|
||||
@forward "components/playlist/epg_view";
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: var(--padding-default);
|
||||
width: 100%;
|
||||
|
||||
&__indicator {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
.tp__epg {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__channels-header,
|
||||
&__timeline {
|
||||
box-sizing: border-box;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
&__timeline {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
&-block {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
box-sizing: border-box;
|
||||
border-left: 1px solid var(--text-color);
|
||||
padding-left: 4px;
|
||||
background-color: var(--background-color);
|
||||
|
||||
&-time, &-date {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
&-date {
|
||||
color: var(--modest-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__channels {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
//gap: var(--gap-default);
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 12;
|
||||
background-color: var(--background-color);
|
||||
border-right: 4px solid var(--background-color);
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
&__channels, &__channels-header {
|
||||
box-sizing: border-box;
|
||||
width: var(--epg-channels-width);
|
||||
min-width: var(--epg-channels-width);
|
||||
max-width: var(--epg-channels-width);
|
||||
}
|
||||
|
||||
&__programs {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
//gap: var(--gap-default);
|
||||
min-width: 100%;
|
||||
height: fit-content;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__channel {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
&__channel-programs {
|
||||
display: block;
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
&__header,
|
||||
&__channels-header,
|
||||
&__timeline {
|
||||
box-sizing: border-box;
|
||||
max-height: var(--epg-header-height);
|
||||
min-height: var(--epg-header-height);
|
||||
height: var(--epg-header-height);
|
||||
}
|
||||
|
||||
&__channel,
|
||||
&__channel-programs {
|
||||
box-sizing: border-box;
|
||||
max-height: var(--epg-row-height);
|
||||
min-height: var(--epg-row-height);
|
||||
height: var(--epg-row-height);
|
||||
}
|
||||
|
||||
&__program {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
box-sizing: border-box;
|
||||
border: 4px solid var(--background-color);
|
||||
padding-left: var(--padding-small);
|
||||
background-color: var(--epg-program-background-color);
|
||||
color: var(--epg-program-color);
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius);
|
||||
height: 100%;
|
||||
|
||||
&-time {
|
||||
color: var(--epg-program-time-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&-title {
|
||||
}
|
||||
|
||||
&-active {
|
||||
background-color: var(--epg-program-active-background-color);
|
||||
color: var(--epg-program-active-color);
|
||||
.tp__epg__program-time {
|
||||
color: var(--epg-program-active-time-color);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&__now-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
height: 100%;
|
||||
width: 2px; /* line thickness */
|
||||
background-color: red;
|
||||
z-index: 11; /* above programs */
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::app::components::{AppIcon, DashboardView, IconButton, InputRow, Panel, PlaylistEditorView, PlaylistExplorerView, PlaylistUpdateView, Sidebar, StatsView, ToastrView, UserlistView, WebsocketStatus};
|
||||
use crate::app::components::{AppIcon, DashboardView, EpgView, IconButton, InputRow, Panel, PlaylistEditorView, PlaylistExplorerView, PlaylistUpdateView, Sidebar, StatsView, ToastrView, UserlistView, WebsocketStatus};
|
||||
use crate::app::context::{ConfigContext, PlaylistContext, StatusContext};
|
||||
use crate::hooks::{use_server_status, use_service_context};
|
||||
use crate::model::{EventMessage, ViewType};
|
||||
@@ -186,6 +186,9 @@ pub fn Home() -> Html {
|
||||
<Panel class="tp__full-width" value={ViewType::PlaylistExplorer.to_string()} active={view_visible.to_string()}>
|
||||
<PlaylistExplorerView/>
|
||||
</Panel>
|
||||
<Panel class="tp__full-width" value={ViewType::PlaylistEpg.to_string()} active={view_visible.to_string()}>
|
||||
<EpgView/>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
use std::rc::Rc;
|
||||
use chrono::{Datelike, Local, TimeZone, Utc};
|
||||
use gloo_timers::callback::Interval;
|
||||
use web_sys::HtmlElement;
|
||||
use crate::app::components::{Breadcrumbs, NoContent, PlaylistSourceSelector};
|
||||
use crate::hooks::use_service_context;
|
||||
use yew::platform::spawn_local;
|
||||
use yew::prelude::*;
|
||||
use yew_i18n::use_translation;
|
||||
use shared::model::{EpgTv, PlaylistRequest, PlaylistRequestType};
|
||||
use crate::model::{BusyStatus, EventMessage, ExplorerSourceType};
|
||||
|
||||
const TIME_BLOCK_WIDTH: f64 = 210.0;
|
||||
const TIME_BLOCK_MINS: i64 = 30;
|
||||
const PIXEL_PER_MIN:f64 = TIME_BLOCK_WIDTH as f64 / TIME_BLOCK_MINS as f64;
|
||||
|
||||
fn get_pos(secs: i64, start_mins: i64) -> i64 {
|
||||
let mins = secs / 60;
|
||||
let rel_mins = mins - start_mins;
|
||||
(rel_mins as f64 * PIXEL_PER_MIN).round() as i64
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn EpgView() -> Html {
|
||||
let services = use_service_context();
|
||||
let translate = use_translation();
|
||||
let epg = use_state::<Option<EpgTv>, _>(|| None);
|
||||
let breadcrumbs = use_state(|| Rc::new(vec![translate.t("LABEL.PLAYLISTS"), translate.t("LABEL.PLAYLIST_EPG")]));
|
||||
let container_ref = use_node_ref();
|
||||
let now_line_ref = use_node_ref();
|
||||
|
||||
let handle_select_source = {
|
||||
let service_ctx = services.clone();
|
||||
let epg_set = epg.clone();
|
||||
Some(Callback::from(move |req: PlaylistRequest| {
|
||||
epg_set.set(None);
|
||||
if req.rtype == PlaylistRequestType::Target {
|
||||
if let Some(target_id) = req.source_id {
|
||||
let service_ctx = service_ctx.clone();
|
||||
let epg_set = epg_set.clone();
|
||||
service_ctx.event.broadcast(EventMessage::Busy(BusyStatus::Show));
|
||||
spawn_local(async move {
|
||||
let playlist_epg = service_ctx.playlist.get_playlist_epg(target_id).await;
|
||||
epg_set.set(playlist_epg);
|
||||
service_ctx.event.broadcast(EventMessage::Busy(BusyStatus::Hide));
|
||||
});
|
||||
}
|
||||
}
|
||||
}))
|
||||
};
|
||||
|
||||
{
|
||||
let container_ref = container_ref.clone();
|
||||
let now_line_ref = now_line_ref.clone();
|
||||
use_effect_with(epg.clone(), move |epg_tv| {
|
||||
// TODO Active elements color change after time update
|
||||
let epg_tv_clone = epg_tv.clone();
|
||||
|
||||
let calculate_position = Rc::new(move |epg_tv: &UseStateHandle<Option<EpgTv>>| {
|
||||
if let Some(tv) = &**epg_tv {
|
||||
if let (Some(div), Some(now_line)) = (container_ref.cast::<HtmlElement>(), now_line_ref.cast::<HtmlElement>()) {
|
||||
let start_window_secs = (tv.start / (TIME_BLOCK_MINS*60)) * (TIME_BLOCK_MINS*60);
|
||||
let start_window = (start_window_secs / 60).max(0);
|
||||
let now = Utc::now().timestamp();
|
||||
let now_line_pos = get_pos(now, start_window);
|
||||
let container_width = div.client_width();
|
||||
let scroll_pos = (now_line_pos as i32 - (container_width >> 1)).max(0);
|
||||
div.set_scroll_left(scroll_pos);
|
||||
now_line.style().set_property("left", &format!("{now_line_pos}px")).unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let calculate_pos = calculate_position.clone();
|
||||
let interval = Interval::new(60_000, move || {
|
||||
calculate_pos(&epg_tv_clone);
|
||||
});
|
||||
calculate_position(epg_tv);
|
||||
|| drop(interval)
|
||||
});
|
||||
}
|
||||
|
||||
html! {
|
||||
<div class="tp__epg tp__list-view">
|
||||
<Breadcrumbs items={&*breadcrumbs}/>
|
||||
<div class="tp__epg__header">
|
||||
<h1>{translate.t("LABEL.PLAYLIST_EPG")}</h1>
|
||||
</div>
|
||||
<PlaylistSourceSelector hide_title={true} source_types={Some(vec![ExplorerSourceType::Hosted])} on_select={handle_select_source} />
|
||||
<div class="tp__epg__body" ref={container_ref} >
|
||||
{
|
||||
if epg.is_none() {
|
||||
html! {
|
||||
<NoContent />
|
||||
}
|
||||
} else {
|
||||
let tv = epg.as_ref().unwrap();
|
||||
let start_window_secs = (tv.start / (TIME_BLOCK_MINS*60)) * (TIME_BLOCK_MINS*60);
|
||||
let start_window = (start_window_secs / 60).max(0);
|
||||
let end_window = (tv.stop / 60).max(0);
|
||||
let window_duration = (end_window - start_window).max(0);
|
||||
let num_blocks = (window_duration + TIME_BLOCK_MINS - 1) / TIME_BLOCK_MINS;
|
||||
let now = Utc::now().timestamp();
|
||||
let now_line_pos = get_pos(now, start_window);
|
||||
let block_style= format!("width:{TIME_BLOCK_WIDTH}px; min-width:{TIME_BLOCK_WIDTH}px; max-width:{TIME_BLOCK_WIDTH}px");
|
||||
html! {
|
||||
<>
|
||||
<div class="tp__epg__channels">
|
||||
<div class="tp__epg__channels-header">
|
||||
</div>
|
||||
{ for tv.channels.iter().map(|ch| {
|
||||
html! {
|
||||
<div class="tp__epg__channel">
|
||||
<div class="tp__epg__channel-title">
|
||||
{ &ch.title }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}
|
||||
</div>
|
||||
<div class="tp__epg__programs">
|
||||
<div class="tp__epg__timeline">
|
||||
{ for (0..num_blocks).map(|i| {
|
||||
let block_start = start_window + i as i64 * TIME_BLOCK_MINS;
|
||||
let start_time_utc = Utc.timestamp_opt(block_start * 60, 0).unwrap();
|
||||
let start_time_local = start_time_utc.with_timezone(&Local);
|
||||
let hour_min = start_time_local.format("%H:%M").to_string();
|
||||
let day_month = format!("{:02}.{:02}", start_time_local.day(), start_time_local.month());
|
||||
html! {
|
||||
<div class="tp__epg__timeline-block" style={block_style.clone()}>
|
||||
<div class="tp__epg__timeline-block-time">{ hour_min }</div>
|
||||
<div class="tp__epg__timeline-block-date">{ day_month }</div>
|
||||
</div>
|
||||
}
|
||||
}) }
|
||||
</div>
|
||||
|
||||
{ for tv.channels.iter().map(|ch| {
|
||||
html! {
|
||||
<div class="tp__epg__channel-programs">
|
||||
{ for ch.programmes.iter().map(|p| {
|
||||
let is_active = now >= p.start && now < p.stop;
|
||||
let left = get_pos(p.start, start_window);
|
||||
let right = get_pos(p.stop, start_window);
|
||||
let width = right - left;
|
||||
|
||||
let pstart_time = Utc.timestamp_opt(p.start, 0).unwrap();
|
||||
let pend_time = Utc.timestamp_opt(p.stop, 0).unwrap();
|
||||
let pstart_time_local = pstart_time.with_timezone(&Local);
|
||||
let pend_time_local = pend_time.with_timezone(&Local);
|
||||
let pstart = pstart_time_local.format("%H:%M").to_string();
|
||||
let pend = pend_time_local.format("%H:%M").to_string();
|
||||
let program_style = format!("left:{left}px; width:{width}px; min-width:{width}px; max-width:{width}px");
|
||||
|
||||
html! {
|
||||
<div class={classes!("tp__epg__program", if is_active { "tp__epg__program-active" } else {""})} style={program_style} title={ p.title.clone() }>
|
||||
<div class="tp__epg__program-time">{ &pstart } {"-"} { &pend }</div>
|
||||
<div class="tp__epg__program-title">{ &p.title }</div>
|
||||
</div>
|
||||
}})}
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}
|
||||
<div ref={now_line_ref} class="tp__epg__now-line" style={format!("left:{now_line_pos}px")}></div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ mod playlist_update_view;
|
||||
mod mapper_script_view;
|
||||
mod mapper_counter_view;
|
||||
mod epg_config_view;
|
||||
mod epg_view;
|
||||
|
||||
use std::rc::Rc;
|
||||
use yew_i18n::YewI18n;
|
||||
@@ -42,6 +43,7 @@ pub use self::playlist_update_view::*;
|
||||
pub use self::mapper_script_view::*;
|
||||
pub use self::mapper_counter_view::*;
|
||||
pub use self::epg_config_view::*;
|
||||
pub use self::epg_view::*;
|
||||
|
||||
pub fn make_tags(data: &[(bool, &str)], translate: &YewI18n) -> Vec<Rc<Tag>> {
|
||||
data.iter()
|
||||
|
||||
@@ -12,19 +12,34 @@ use yew::prelude::*;
|
||||
use yew_i18n::use_translation;
|
||||
use crate::app::components::input::Input;
|
||||
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct PlaylistSourceSelectorProps {
|
||||
#[prop_or_default]
|
||||
pub hide_title: bool,
|
||||
#[prop_or_default]
|
||||
pub source_types: Option<Vec<ExplorerSourceType>>,
|
||||
#[prop_or_default]
|
||||
pub on_select: Option<Callback<PlaylistRequest>>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn PlaylistSourceSelector() -> Html {
|
||||
pub fn PlaylistSourceSelector(props: &PlaylistSourceSelectorProps) -> Html {
|
||||
let translate = use_translation();
|
||||
let services_ctx = use_service_context();
|
||||
let playlist_ctx = use_context::<PlaylistContext>().expect("Playlist context not found");
|
||||
let playlist_explorer_ctx = use_context::<PlaylistExplorerContext>().expect("PlaylistExplorer context not found");
|
||||
let playlist_explorer_ctx = use_context::<PlaylistExplorerContext>();
|
||||
let active_source = use_state(|| ExplorerSourceType::Hosted);
|
||||
let loading = use_state(|| false);
|
||||
let custom_provider = use_state(|| InputType::Xtream);
|
||||
let username_ref = use_node_ref();
|
||||
let password_ref = use_node_ref();
|
||||
let url_ref = use_node_ref();
|
||||
let source_types = use_memo((), |_| vec![ExplorerSourceType::Hosted.to_string(), ExplorerSourceType::Provider.to_string(), ExplorerSourceType::Custom.to_string()]);
|
||||
let source_types = use_memo(props.source_types.clone(), |st| {
|
||||
let st = st.as_ref().map(|v| v.as_slice()).unwrap_or(&[
|
||||
ExplorerSourceType::Hosted, ExplorerSourceType::Provider, ExplorerSourceType::Custom,
|
||||
]);
|
||||
st.iter().map(ToString::to_string).collect::<Vec<String>>()
|
||||
});
|
||||
|
||||
let handle_source_select = {
|
||||
let active_source_clone = active_source.clone();
|
||||
@@ -39,25 +54,32 @@ pub fn PlaylistSourceSelector() -> Html {
|
||||
|
||||
let handle_source_download = {
|
||||
let services = services_ctx.clone();
|
||||
let playlist_explorer_ctx_clone = playlist_explorer_ctx.clone();
|
||||
let set_loading = loading.clone();
|
||||
Callback::from(move |request: PlaylistRequest| {
|
||||
if !*set_loading {
|
||||
let services = services.clone();
|
||||
let playlist_explorer_ctx_clone = playlist_explorer_ctx_clone.clone();
|
||||
set_loading.set(true);
|
||||
services.event.broadcast(EventMessage::Busy(BusyStatus::Show));
|
||||
let set_loading = set_loading.clone();
|
||||
let req = request;
|
||||
spawn_local(async move {
|
||||
let playlist = services.playlist.get_playlist_categories(&req).await;
|
||||
playlist_explorer_ctx_clone.playlist.set(playlist);
|
||||
playlist_explorer_ctx_clone.playlist_request.set(Some(req));
|
||||
set_loading.set(false);
|
||||
services.event.broadcast(EventMessage::Busy(BusyStatus::Hide));
|
||||
});
|
||||
}
|
||||
})
|
||||
if let Some(on_select) = &props.on_select {
|
||||
let on_select = on_select.clone();
|
||||
Callback::from(move |request: PlaylistRequest| {
|
||||
on_select.emit(request)
|
||||
})
|
||||
} else {
|
||||
let playlist_explorer_ctx_clone = playlist_explorer_ctx.expect("PlaylistExplorer context not found").clone();
|
||||
Callback::from(move |request: PlaylistRequest| {
|
||||
if !*set_loading {
|
||||
let services = services.clone();
|
||||
let playlist_explorer_ctx_clone = playlist_explorer_ctx_clone.clone();
|
||||
set_loading.set(true);
|
||||
services.event.broadcast(EventMessage::Busy(BusyStatus::Show));
|
||||
let set_loading = set_loading.clone();
|
||||
let req = request;
|
||||
spawn_local(async move {
|
||||
let playlist = services.playlist.get_playlist_categories(&req).await;
|
||||
playlist_explorer_ctx_clone.playlist.set(playlist);
|
||||
playlist_explorer_ctx_clone.playlist_request.set(Some(req));
|
||||
set_loading.set(false);
|
||||
services.event.broadcast(EventMessage::Busy(BusyStatus::Hide));
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let handle_defined_source = {
|
||||
@@ -129,7 +151,6 @@ pub fn PlaylistSourceSelector() -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
let render_hosted = {
|
||||
let playlist_ctx_clone = playlist_ctx.clone();
|
||||
let handle_defined_source = handle_defined_source.clone();
|
||||
@@ -240,9 +261,11 @@ pub fn PlaylistSourceSelector() -> Html {
|
||||
|
||||
html! {
|
||||
<div class="tp__playlist-source-selector tp__list-list">
|
||||
<div class="tp__playlist-source-selector__header tp__list-list__header">
|
||||
<h1>{ translate.t("LABEL.SOURCES")}</h1>
|
||||
</div>
|
||||
{ html_if!(!props.hide_title, {
|
||||
<div class="tp__playlist-source-selector__header tp__list-list__header">
|
||||
<h1>{ translate.t("LABEL.SOURCES")}</h1>
|
||||
</div>
|
||||
})}
|
||||
<div class="tp__playlist-source-selector__body tp__list-list__body">
|
||||
<CollapsePanel class="tp__playlist-source-selector__source-picker" expanded={true}
|
||||
title={translate.t("LABEL.SOURCE_PICKER")}>
|
||||
|
||||
@@ -143,6 +143,7 @@ pub fn Sidebar(props: &SidebarProps) -> Html {
|
||||
<MenuItem class={if *active_menu == ViewType::PlaylistUpdate { "active" } else {""}} icon="Refresh" name={ViewType::PlaylistUpdate.to_string()} label={translate.t("LABEL.UPDATE")} onclick={&handle_menu_click}></MenuItem>
|
||||
<MenuItem class={if *active_menu == ViewType::PlaylistEditor { "active" } else {""}} icon="PlayArrowOutline" name={ViewType::PlaylistEditor.to_string()} label={translate.t("LABEL.PLAYLIST")} onclick={&handle_menu_click}></MenuItem>
|
||||
<MenuItem class={if *active_menu == ViewType::PlaylistExplorer { "active" } else {""}} icon="Live" name={ViewType::PlaylistExplorer.to_string()} label={translate.t("LABEL.PLAYLIST_VIEWER")} onclick={&handle_menu_click}></MenuItem>
|
||||
<MenuItem class={if *active_menu == ViewType::PlaylistEpg { "active" } else {""}} icon="Epg" name={ViewType::PlaylistEpg.to_string()} label={translate.t("LABEL.PLAYLIST_EPG")} onclick={&handle_menu_click}></MenuItem>
|
||||
</CollapsePanel>
|
||||
</div>
|
||||
}
|
||||
@@ -160,6 +161,7 @@ pub fn Sidebar(props: &SidebarProps) -> Html {
|
||||
<IconButton class={if *active_menu == ViewType::PlaylistUpdate { "active" } else {""}} icon="Refresh" name={ViewType::PlaylistUpdate.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={if *active_menu == ViewType::PlaylistEditor { "active" } else {""}} icon="PlayArrowOutline" name={ViewType::PlaylistEditor.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={if *active_menu == ViewType::PlaylistExplorer { "active" } else {""}} icon="Live" name={ViewType::PlaylistExplorer.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={if *active_menu == ViewType::PlaylistEpg { "active" } else {""}} icon="Epg" name={ViewType::PlaylistEpg.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ pub struct WebsocketStatusProps {
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn WebsocketStatus(props: &WebsocketStatusProps) -> Html {
|
||||
pub fn WebsocketStatus(_props: &WebsocketStatusProps) -> Html {
|
||||
let status = use_state(|| true);
|
||||
let services = use_service_context();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const CONFIG: &str = "config";
|
||||
const PLAYLIST_UPDATE: &str = "playlist_update";
|
||||
const PLAYLIST_EDITOR: &str = "playlist_editor";
|
||||
const PLAYLIST_EXPLORER: &str = "playlist_explorer";
|
||||
const PLAYLIST_EPG: &str = "playlist_epg";
|
||||
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
@@ -19,7 +20,8 @@ pub enum ViewType {
|
||||
Config,
|
||||
PlaylistUpdate,
|
||||
PlaylistEditor,
|
||||
PlaylistExplorer
|
||||
PlaylistExplorer,
|
||||
PlaylistEpg
|
||||
}
|
||||
|
||||
impl FromStr for ViewType {
|
||||
@@ -34,6 +36,7 @@ impl FromStr for ViewType {
|
||||
PLAYLIST_UPDATE => Ok(ViewType::PlaylistUpdate),
|
||||
PLAYLIST_EDITOR => Ok(ViewType::PlaylistEditor),
|
||||
PLAYLIST_EXPLORER => Ok(ViewType::PlaylistExplorer),
|
||||
PLAYLIST_EPG => Ok(ViewType::PlaylistEpg),
|
||||
_ => Err(info_err!(format!("Unknown view type: {s}"))),
|
||||
}
|
||||
}
|
||||
@@ -49,6 +52,7 @@ impl fmt::Display for ViewType {
|
||||
ViewType::PlaylistUpdate => PLAYLIST_UPDATE,
|
||||
ViewType::PlaylistEditor => PLAYLIST_EDITOR,
|
||||
ViewType::PlaylistExplorer => PLAYLIST_EXPLORER,
|
||||
ViewType::PlaylistEpg => PLAYLIST_EPG,
|
||||
};
|
||||
write!(f, "{s}")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::services::{get_base_href, request_post};
|
||||
use log::error;
|
||||
use shared::model::{CommonPlaylistItem, PlaylistCategoriesResponse, PlaylistRequest, UiPlaylistCategories, WebplayerUrlRequest};
|
||||
use shared::model::{CommonPlaylistItem, EpgTv, PlaylistCategoriesResponse, PlaylistEpgRequest, PlaylistRequest, UiPlaylistCategories, WebplayerUrlRequest};
|
||||
use std::rc::Rc;
|
||||
use shared::utils::{concat_path_leading_slash};
|
||||
|
||||
@@ -8,6 +8,7 @@ pub struct PlaylistService {
|
||||
target_update_api_path: String,
|
||||
playlist_api_path: String,
|
||||
playlist_api_webplayer_url_path: String,
|
||||
playlist_api_epg_path: String,
|
||||
}
|
||||
impl Default for PlaylistService {
|
||||
fn default() -> Self {
|
||||
@@ -22,6 +23,7 @@ impl PlaylistService {
|
||||
target_update_api_path: concat_path_leading_slash(&base_href, "api/v1/playlist/update"),
|
||||
playlist_api_path: concat_path_leading_slash(&base_href, "api/v1/playlist"),
|
||||
playlist_api_webplayer_url_path: concat_path_leading_slash(&base_href, "api/v1/playlist/webplayer"),
|
||||
playlist_api_epg_path: concat_path_leading_slash(&base_href, "api/v1/playlist/epg"),
|
||||
}
|
||||
}
|
||||
pub async fn update_targets(&self, targets: &[&str]) -> bool {
|
||||
@@ -52,4 +54,17 @@ impl PlaylistService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_playlist_epg(&self, target_id: u16) -> Option<EpgTv> {
|
||||
let request = PlaylistEpgRequest {
|
||||
target_id,
|
||||
};
|
||||
match request_post::<&PlaylistEpgRequest, EpgTv>(&self.playlist_api_epg_path, &request, None, None).await {
|
||||
Ok(content) => Some(content),
|
||||
Err(err) => {
|
||||
error!("{err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub fn parse_xmltv_time(t: &str) -> Option<i64> {
|
||||
let fmt = "%Y%m%d%H%M%S %z";
|
||||
if let Ok(result) = DateTime::parse_from_str(t, fmt) {
|
||||
Some(result
|
||||
.with_timezone(&Utc)
|
||||
.timestamp())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct EpgTv {
|
||||
pub start: i64,
|
||||
pub stop: i64,
|
||||
pub channels: Vec<EpgChannel>,
|
||||
}
|
||||
|
||||
impl PartialEq for EpgTv {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.start == other.start
|
||||
&& self.stop == other.stop
|
||||
// Note: self.channels is skipped
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct EpgChannel {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub programmes: Vec<EpgProgramme>,
|
||||
}
|
||||
|
||||
impl EpgChannel {
|
||||
pub fn new(id: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
title: String::new(),
|
||||
programmes: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct EpgProgramme {
|
||||
pub start: i64,
|
||||
pub stop: i64,
|
||||
pub channel: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
impl EpgProgramme {
|
||||
pub fn new(start: i64, stop: i64, channel: String) -> Self {
|
||||
Self {
|
||||
start,
|
||||
stop,
|
||||
channel,
|
||||
title: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod playlist_request;
|
||||
mod playlist_categories;
|
||||
mod search_request;
|
||||
mod webplayer_url_request;
|
||||
mod epg;
|
||||
|
||||
pub use self::cluster_flags::*;
|
||||
pub use self::playlist::*;
|
||||
@@ -34,3 +35,4 @@ pub use self::playlist_request::*;
|
||||
pub use self::playlist_categories::*;
|
||||
pub use self::search_request::*;
|
||||
pub use self::webplayer_url_request::*;
|
||||
pub use self::epg::*;
|
||||
|
||||
@@ -181,4 +181,9 @@ impl UiPlaylistCategories {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct PlaylistEpgRequest {
|
||||
pub target_id: u16,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user