diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b143772..0b515eff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/src/api/endpoints/mod.rs b/backend/src/api/endpoints/mod.rs index 1beddb251..d1fde0a58 100644 --- a/backend/src/api/endpoints/mod.rs +++ b/backend/src/api/endpoints/mod.rs @@ -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; \ No newline at end of file +pub (in crate::api) mod websocket_api; +mod v1_api_playlist; +mod v1_api_user; +mod v1_api_config; \ No newline at end of file diff --git a/backend/src/api/endpoints/v1_api.rs b/backend/src/api/endpoints/v1_api.rs index ac50215bd..4339cf709 100644 --- a/backend/src/api/endpoints/v1_api.rs +++ b/backend/src/api/endpoints/v1_api.rs @@ -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 { - 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 { - 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>, - axum::extract::Path(target_name): axum::extract::Path, - axum::extract::Json(mut credential): axum::extract::Json, -) -> 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>, - 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>, - axum::extract::Json(cfg): axum::extract::Json, -) -> 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>, - axum::extract::Json(mut req_api_proxy): axum::extract::Json>, -) -> 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>, - axum::extract::Json(targets): axum::extract::Json>, -) -> impl axum::response::IntoResponse + Send { - let user_targets = if targets.is_empty() { None } else { Some(targets) }; - let process_targets = app_state.app_config.sources.load().validate_targets(user_targets.as_ref()); - match process_targets { - Ok(valid_targets) => { - let 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>, - axum::extract::Json(playlist_req): axum::extract::Json, -) -> 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>, - axum::extract::Json(playlist_item): axum::extract::Json, -) -> 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>, -) -> 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, - axum::extract::State(app_state): axum::extract::State>, -) -> 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) -> Option<(Option, Option)> { let config = app_state.app_config.config.load(); @@ -429,19 +78,12 @@ pub fn v1_api_register(web_auth_enabled: bool, app_state: Arc, 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)); } diff --git a/backend/src/api/endpoints/v1_api_config.rs b/backend/src/api/endpoints/v1_api_config.rs new file mode 100644 index 000000000..5489a06ce --- /dev/null +++ b/backend/src/api/endpoints/v1_api_config.rs @@ -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 { + 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 { + 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>, + axum::extract::Json(cfg): axum::extract::Json, +) -> 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>, + axum::extract::Json(mut req_api_proxy): axum::extract::Json>, +) -> 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>, +) -> 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, + axum::extract::State(app_state): axum::extract::State>, +) -> 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>) -> axum::Router> { + 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)) +} diff --git a/backend/src/api/endpoints/v1_api_playlist.rs b/backend/src/api/endpoints/v1_api_playlist.rs new file mode 100644 index 000000000..5a52dfee7 --- /dev/null +++ b/backend/src/api/endpoints/v1_api_playlist.rs @@ -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>, + axum::extract::Json(targets): axum::extract::Json>, +) -> impl axum::response::IntoResponse + Send { + let user_targets = if targets.is_empty() { None } else { Some(targets) }; + let process_targets = app_state.app_config.sources.load().validate_targets(user_targets.as_ref()); + match process_targets { + Ok(valid_targets) => { + let 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>, + axum::extract::Json(playlist_req): axum::extract::Json, +) -> 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>, + axum::extract::Json(playlist_item): axum::extract::Json, +) -> 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>, + axum::extract::Json(playlist_epg_req): axum::extract::Json, +) -> 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>) -> axum::Router> { + 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)) +} diff --git a/backend/src/api/endpoints/v1_api_user.rs b/backend/src/api/endpoints/v1_api_user.rs new file mode 100644 index 000000000..c5e25035f --- /dev/null +++ b/backend/src/api/endpoints/v1_api_user.rs @@ -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>, + axum::extract::Path(target_name): axum::extract::Path, + axum::extract::Json(mut credential): axum::extract::Json, +) -> 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>, + 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>) -> axum::Router> { + 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)) +} diff --git a/backend/src/api/endpoints/xmltv_api.rs b/backend/src/api/endpoints/xmltv_api.rs index 87bd60501..8a3f1ca15 100644 --- a/backend/src/api/endpoints/xmltv_api.rs +++ b/backend/src/api/endpoints/xmltv_api.rs @@ -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 { +pub (in crate::api) fn get_epg_path_for_target(config: &Config, target: &ConfigTarget) -> Option { // TODO if we have multiple targets, first one serves, this can be problematic when // we use m3u playlist but serve xtream target epg diff --git a/backend/src/model/config/app.rs b/backend/src/model/config/app.rs index db3220c4b..f030b87de 100644 --- a/backend/src/model/config/app.rs +++ b/backend/src/model/config/app.rs @@ -159,7 +159,7 @@ impl AppConfig { None => None } } - + pub fn get_inputs_for_target(&self, target_name: &str) -> Option>> { let sources = > as Access>::load(&self.sources); for source in &sources.sources { diff --git a/backend/src/model/mod.rs b/backend/src/model/mod.rs index 73eac0655..158171a36 100644 --- a/backend/src/model/mod.rs +++ b/backend/src/model/mod.rs @@ -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::*; \ No newline at end of file +pub use self::input_source::*; diff --git a/backend/src/model/xmltv.rs b/backend/src/model/xmltv.rs index f3c133bc8..a6331a2f7 100644 --- a/backend/src/model/xmltv.rs +++ b/backend/src/model/xmltv.rs @@ -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, + programmes: &mut Vec, +) { + 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) -> (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 { + 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 = None; + let mut current_programme: Option = 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, + }) +} diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 91edeec52..815e50f08 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -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", diff --git a/frontend/public/assets/icons.json b/frontend/public/assets/icons.json index edaa1443e..51eaf9c6f 100644 --- a/frontend/public/assets/icons.json +++ b/frontend/public/assets/icons.json @@ -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" } ] \ No newline at end of file diff --git a/frontend/scss/_theme.scss b/frontend/scss/_theme.scss index 145ce284f..39cb1652e 100644 --- a/frontend/scss/_theme.scss +++ b/frontend/scss/_theme.scss @@ -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"] { diff --git a/frontend/scss/app/_component.scss b/frontend/scss/app/_component.scss index baf00c0aa..29e80e776 100644 --- a/frontend/scss/app/_component.scss +++ b/frontend/scss/app/_component.scss @@ -72,3 +72,4 @@ @forward "components/keyvalue_editor"; @forward "components/loading_screen"; @forward "components/websocket_status"; +@forward "components/playlist/epg_view"; diff --git a/frontend/scss/app/components/_no_content.scss b/frontend/scss/app/components/_no_content.scss index 4c0635a8d..16f383908 100644 --- a/frontend/scss/app/components/_no_content.scss +++ b/frontend/scss/app/components/_no_content.scss @@ -4,6 +4,7 @@ justify-content: center; align-items: center; padding: var(--padding-default); + width: 100%; &__indicator { display: flex; diff --git a/frontend/scss/app/components/playlist/_epg_view.scss b/frontend/scss/app/components/playlist/_epg_view.scss new file mode 100644 index 000000000..8fc54294a --- /dev/null +++ b/frontend/scss/app/components/playlist/_epg_view.scss @@ -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 */ + } +} \ No newline at end of file diff --git a/frontend/src/app/components/home.rs b/frontend/src/app/components/home.rs index 4e8297ee8..acd16429e 100644 --- a/frontend/src/app/components/home.rs +++ b/frontend/src/app/components/home.rs @@ -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 { + + + diff --git a/frontend/src/app/components/playlist/epg_view.rs b/frontend/src/app/components/playlist/epg_view.rs new file mode 100644 index 000000000..4db0a469c --- /dev/null +++ b/frontend/src/app/components/playlist/epg_view.rs @@ -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::, _>(|| 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>| { + if let Some(tv) = &**epg_tv { + if let (Some(div), Some(now_line)) = (container_ref.cast::(), now_line_ref.cast::()) { + 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! { +
+ +
+

{translate.t("LABEL.PLAYLIST_EPG")}

+
+ +
+ { + if epg.is_none() { + html! { + + } + } 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! { + <> +
+
+
+ { for tv.channels.iter().map(|ch| { + html! { +
+
+ { &ch.title } +
+
+ } + }) + } +
+
+
+ { 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! { +
+
{ hour_min }
+
{ day_month }
+
+ } + }) } +
+ + { for tv.channels.iter().map(|ch| { + html! { +
+ { 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! { +
+
{ &pstart } {"-"} { &pend }
+
{ &p.title }
+
+ }})} +
+ } + }) + } +
+
+ + } + } + } +
+
+ } +} diff --git a/frontend/src/app/components/playlist/mod.rs b/frontend/src/app/components/playlist/mod.rs index 666a4bfd3..733540520 100644 --- a/frontend/src/app/components/playlist/mod.rs +++ b/frontend/src/app/components/playlist/mod.rs @@ -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> { data.iter() diff --git a/frontend/src/app/components/playlist/source_selector.rs b/frontend/src/app/components/playlist/source_selector.rs index 97327dc45..e9ef12154 100644 --- a/frontend/src/app/components/playlist/source_selector.rs +++ b/frontend/src/app/components/playlist/source_selector.rs @@ -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>, + #[prop_or_default] + pub on_select: Option>, +} + #[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::().expect("Playlist context not found"); - let playlist_explorer_ctx = use_context::().expect("PlaylistExplorer context not found"); + let playlist_explorer_ctx = use_context::(); 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::>() + }); 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! {
-
-

{ translate.t("LABEL.SOURCES")}

-
+ { html_if!(!props.hide_title, { +
+

{ translate.t("LABEL.SOURCES")}

+
+ })}
diff --git a/frontend/src/app/components/sidebar.rs b/frontend/src/app/components/sidebar.rs index f5a7bea5d..1cdd70c20 100644 --- a/frontend/src/app/components/sidebar.rs +++ b/frontend/src/app/components/sidebar.rs @@ -143,6 +143,7 @@ pub fn Sidebar(props: &SidebarProps) -> Html { +
} @@ -160,6 +161,7 @@ pub fn Sidebar(props: &SidebarProps) -> Html { +
} }; diff --git a/frontend/src/app/components/websocket_status.rs b/frontend/src/app/components/websocket_status.rs index 996f3753e..3ef7f802f 100644 --- a/frontend/src/app/components/websocket_status.rs +++ b/frontend/src/app/components/websocket_status.rs @@ -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(); diff --git a/frontend/src/model/view_type.rs b/frontend/src/model/view_type.rs index 5fe215c8b..3e47a1553 100644 --- a/frontend/src/model/view_type.rs +++ b/frontend/src/model/view_type.rs @@ -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}") } diff --git a/frontend/src/services/playlist_service.rs b/frontend/src/services/playlist_service.rs index d1cb05c69..3f6f6d226 100644 --- a/frontend/src/services/playlist_service.rs +++ b/frontend/src/services/playlist_service.rs @@ -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 { + 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 + } + } + } } diff --git a/shared/src/model/epg.rs b/shared/src/model/epg.rs new file mode 100644 index 000000000..d4a3bfe4b --- /dev/null +++ b/shared/src/model/epg.rs @@ -0,0 +1,64 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +pub fn parse_xmltv_time(t: &str) -> Option { + 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, +} + +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, +} + +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(), + } + } +} diff --git a/shared/src/model/mod.rs b/shared/src/model/mod.rs index cd88c1fbe..c6327b0fd 100644 --- a/shared/src/model/mod.rs +++ b/shared/src/model/mod.rs @@ -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::*; diff --git a/shared/src/model/playlist_request.rs b/shared/src/model/playlist_request.rs index 1a4801cc5..279546451 100644 --- a/shared/src/model/playlist_request.rs +++ b/shared/src/model/playlist_request.rs @@ -181,4 +181,9 @@ impl UiPlaylistCategories { } } } -} \ No newline at end of file +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct PlaylistEpgRequest { + pub target_id: u16, +}