diff --git a/Cargo.lock b/Cargo.lock index bea55808c..872d982f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2580,11 +2580,21 @@ dependencies = [ name = "shared" version = "0.1.0" dependencies = [ + "base64 0.22.1", "bitflags 2.9.1", + "blake3", + "clap", "enum-iterator", + "indexmap", + "log", + "path-clean", + "pest", + "pest_derive", "rand", "regex", "serde", + "serde_json", + "url", ] [[package]] diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 4df0cd9c2..9880a7dd5 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -13,15 +13,14 @@ use crate::api::model::streams::shared_stream_manager::SharedStreamManager; use crate::api::model::streams::throttled_stream::ThrottledStream; use crate::auth::Claims; use crate::model::{ConfigTarget, ProxyUserCredentials}; -use crate::model::{ConfigInput, InputFetchMethod}; -use shared::model::{PlaylistEntry, PlaylistItemType, TargetType, UserConnectionPermission, XtreamCluster}; +use crate::model::{ConfigInput}; +use shared::model::{InputFetchMethod, PlaylistEntry, PlaylistItemType, TargetType, UserConnectionPermission, XtreamCluster}; use crate::tools::atomic_once_flag::AtomicOnceFlag; use crate::tools::lru_cache::LRUResourceCache; -use shared::utils::{DASH_EXT, HLS_EXT}; +use shared::utils::{extract_extension_from_url, replace_url_extension, sanitize_sensitive_info, DASH_EXT, HLS_EXT}; use shared::utils::{default_grace_period_millis, human_readable_byte_size}; use crate::utils::create_new_file_for_write; use crate::utils::request; -use crate::utils::request::{extract_extension_from_url, replace_url_extension, sanitize_sensitive_info}; use crate::utils::{debug_if_enabled, trace_if_enabled}; use crate::{BUILD_TIMESTAMP}; use axum::body::Body; @@ -119,7 +118,7 @@ pub async fn serve_file(file_path: &Path, mime_type: mime::Mime) -> impl axum::r axum::http::StatusCode::NOT_FOUND.into_response() } -pub fn get_user_target_by_username<'a>(username: &str, app_state: &'a AppState) -> Option<(ProxyUserCredentials, &'a ConfigTarget)> { +pub fn get_user_target_by_username(username: &str, app_state: &AppState) -> Option<(ProxyUserCredentials, Arc)> { if !username.is_empty() { return app_state.config.get_target_for_username(username); } @@ -127,7 +126,7 @@ pub fn get_user_target_by_username<'a>(username: &str, app_state: &'a AppState) } pub fn get_user_target_by_credentials<'a>(username: &str, password: &str, api_req: &'a UserApiRequest, - app_state: &'a AppState) -> Option<(ProxyUserCredentials, &'a ConfigTarget)> { + app_state: &'a AppState) -> Option<(ProxyUserCredentials, Arc)> { if !username.is_empty() && !password.is_empty() { app_state.config.get_target_for_user(username, password) } else { @@ -140,7 +139,7 @@ pub fn get_user_target_by_credentials<'a>(username: &str, password: &str, api_re } } -pub fn get_user_target<'a>(api_req: &'a UserApiRequest, app_state: &'a AppState) -> Option<(ProxyUserCredentials, &'a ConfigTarget)> { +pub fn get_user_target<'a>(api_req: &'a UserApiRequest, app_state: &'a AppState) -> Option<(ProxyUserCredentials, Arc)> { let username = api_req.username.as_str().trim(); let password = api_req.password.as_str().trim(); get_user_target_by_credentials(username, password, api_req, app_state) @@ -175,7 +174,7 @@ pub struct StreamOptions { /// Returns a `StreamOptions` instance with the resolved configuration. fn get_stream_options(app_state: &AppState) -> StreamOptions { let (stream_retry, stream_force_retry_secs, buffer_enabled, buffer_size) = app_state - .config + .config.config.load() .reverse_proxy .as_ref() .and_then(|reverse_proxy| reverse_proxy.stream.as_ref()) @@ -346,7 +345,7 @@ async fn create_stream_response_details(app_state: &AppState, force_provider: Option<&str>) -> StreamDetails { let mut streaming_strategy = resolve_streaming_strategy(app_state, stream_url, input, force_provider).await; - let config_grace_period_millis = app_state.config.reverse_proxy.as_ref() + let config_grace_period_millis = app_state.config.config.load().reverse_proxy.as_ref() .and_then(|r| r.stream.as_ref()).map_or_else(default_grace_period_millis, |s| s.grace_period_millis); let grace_period_millis = get_grace_period_millis(connection_permission, &streaming_strategy.provider_stream_state, config_grace_period_millis); match streaming_strategy.provider_stream_state { @@ -647,7 +646,7 @@ pub async fn stream_response(app_state: &AppState, } fn get_stream_throttle(app_state: &AppState) -> u64 { - app_state.config + app_state.config.config.load() .reverse_proxy .as_ref() .and_then(|reverse_proxy| reverse_proxy.stream.as_ref()) @@ -776,7 +775,7 @@ pub fn get_username_from_auth_header( token: &str, app_state: &Arc, ) -> Option { - if let Some(web_auth_config) = &app_state.config.web_ui.as_ref().and_then(|c| c.auth.as_ref()) { + if let Some(web_auth_config) = &app_state.config.config.load().web_ui.as_ref().and_then(|c| c.auth.as_ref()) { let secret_key: &str = web_auth_config.secret.as_ref(); if let Ok(token_data) = decode::( token, diff --git a/backend/src/api/endpoints/api_playlist_utils.rs b/backend/src/api/endpoints/api_playlist_utils.rs index 26045c598..59f6b0a03 100644 --- a/backend/src/api/endpoints/api_playlist_utils.rs +++ b/backend/src/api/endpoints/api_playlist_utils.rs @@ -1,6 +1,5 @@ -use crate::model::{Config, ConfigInput, ConfigTarget, InputType}; -use crate::model::{M3uPlaylistItem, PlaylistGroup}; -use shared::model::{PlaylistItemType, TargetType, XtreamCluster}; +use crate::model::{AppConfig, Config, ConfigInput, ConfigTarget}; +use shared::model::{InputType, M3uPlaylistItem, PlaylistGroup, PlaylistItemType, TargetType, XtreamCluster}; use crate::repository::{m3u_repository, xtream_repository}; use crate::utils::{m3u, xtream}; use axum::response::IntoResponse; @@ -125,7 +124,7 @@ fn group_playlist_groups_by_cluster(playlist: Vec, input_type: In async fn grouped_channels( - cfg: &Arc, + cfg: &AppConfig, target: &ConfigTarget, cluster: XtreamCluster, ) -> Option> { @@ -137,7 +136,7 @@ async fn grouped_channels( )) } -pub(in crate::api::endpoints) async fn get_playlist_for_target(cfg_target: Option<&ConfigTarget>, cfg: &Arc) -> impl axum::response::IntoResponse + Send { +pub(in crate::api::endpoints) async fn get_playlist_for_target(cfg_target: Option<&ConfigTarget>, cfg: &AppConfig) -> impl axum::response::IntoResponse + Send { if let Some(target) = cfg_target { if target.has_output(&TargetType::Xtream) { let live_channels = grouped_channels(cfg, target, XtreamCluster::Live).await; diff --git a/backend/src/api/endpoints/download_api.rs b/backend/src/api/endpoints/download_api.rs index d58146e8e..5b4ae5372 100644 --- a/backend/src/api/endpoints/download_api.rs +++ b/backend/src/api/endpoints/download_api.rs @@ -1,6 +1,6 @@ use crate::api::model::app_state::AppState; use crate::api::model::download::{DownloadQueue, FileDownload, FileDownloadRequest}; -use crate::model::{Config, VideoDownloadConfig}; +use crate::model::{AppConfig, Config, VideoDownloadConfig}; use crate::utils::request; use tokio::sync::RwLock; use futures::stream::TryStreamExt; @@ -11,6 +11,8 @@ use std::io::{Write}; use std::ops::Deref; use std::sync::Arc; use std::{fs}; +use arc_swap::access::Access; +use arc_swap::ArcSwap; use axum::response::IntoResponse; use shared::utils::bytes_to_megabytes; use shared::error::to_io_error; @@ -63,7 +65,7 @@ async fn download_file(active: Arc>>, client: &reqwe } } -async fn run_download_queue(cfg: &Config, download_cfg: &VideoDownloadConfig, download_queue: &Arc) -> Result<(), String> { +async fn run_download_queue(cfg: &AppConfig, download_cfg: &VideoDownloadConfig, download_queue: &Arc) -> Result<(), String> { let next_download = download_queue.as_ref().queue.lock().await.pop_front(); if next_download.is_some() { { *download_queue.as_ref().active.write().await = next_download; } @@ -116,7 +118,10 @@ pub async fn queue_download_file( axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(req): axum::extract::Json, ) -> impl axum::response::IntoResponse + Send { - if let Some(download_cfg) = &app_state.config.video.as_ref().unwrap().download { + let app_config = &*app_state.config; + + let config = > as Access>::load(&app_config.config); + if let Some(download_cfg) = config.video.as_ref().unwrap().download.as_ref() { if download_cfg.directory.is_none() { return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Server config missing video.download.directory configuration"}))).into_response(); } diff --git a/backend/src/api/endpoints/hdhomerun_api.rs b/backend/src/api/endpoints/hdhomerun_api.rs index 784f27a9e..b90a0be97 100644 --- a/backend/src/api/endpoints/hdhomerun_api.rs +++ b/backend/src/api/endpoints/hdhomerun_api.rs @@ -1,13 +1,10 @@ use crate::api::model::app_state::HdHomerunAppState; use crate::auth::AuthBasic; -use crate::model::{ConfigTarget, ProxyUserCredentials}; -use crate::model::{Config}; -use crate::model::{M3uPlaylistItem, XtreamPlaylistItem}; -use shared::model::{PlaylistItemType, TargetType, XtreamCluster}; +use crate::model::{AppConfig, ConfigTarget, ProxyUserCredentials}; +use shared::model::{M3uPlaylistItem, PlaylistItemType, TargetType, XtreamCluster, XtreamPlaylistItem}; use crate::processing::parser::xtream::get_xtream_url; use crate::repository::m3u_playlist_iterator::M3uPlaylistIterator; use crate::repository::xtream_playlist_iterator::XtreamPlaylistIterator; -use crate::utils::get_string_from_serde_value; use axum::response::IntoResponse; use bytes::Bytes; use futures::{stream, Stream, StreamExt}; @@ -15,7 +12,7 @@ use log::{error, warn}; use serde::{Deserialize, Serialize}; use serde_json::json; use std::sync::Arc; - +use shared::utils::get_string_from_serde_value; // https://info.hdhomerun.com/info/http_api // const DISCOVERY_BYTES: &[u8] = &[0, 2, 0, 12, 1, 4, 255, 255, 255, 255, 2, 4, 255, 255, 255, 255, 115, 204, 125, 143]; @@ -87,7 +84,7 @@ impl Device { } } -fn xtream_item_to_lineup_stream(cfg: Arc, cluster: XtreamCluster, credentials: Arc, +fn xtream_item_to_lineup_stream(cfg: Arc, cluster: XtreamCluster, credentials: Arc, base_url: Option, channels: Option) -> impl Stream> where I: Iterator + 'static, @@ -223,7 +220,7 @@ async fn lineup_status() -> impl IntoResponse { })) } -async fn lineup(app_state: &Arc, cfg: &Arc, credentials: &Arc, target: &ConfigTarget) -> impl IntoResponse { +async fn lineup(app_state: &Arc, cfg: &Arc, credentials: &Arc, target: &ConfigTarget) -> impl IntoResponse { let use_output = target.get_hdhomerun_output().as_ref().and_then(|o| o.use_output); let use_all = use_output.is_none(); let use_m3u = use_output.as_ref() == Some(&TargetType::M3u); @@ -282,7 +279,7 @@ async fn auth_lineup_json(AuthBasic((username, password)): AuthBasic, axum::extr return axum::http::StatusCode::UNAUTHORIZED.into_response(); } let user_credentials = Arc::new(credentials); - return lineup(&app_state, &cfg, &user_credentials, target).await.into_response(); + return lineup(&app_state, &cfg, &user_credentials, &target).await.into_response(); } axum::http::StatusCode::NOT_FOUND.into_response() } @@ -291,7 +288,7 @@ async fn lineup_json(axum::extract::State(app_state): axum::extract::State impl axum::response::IntoResponse + Send { match get_user_target(api_req, app_state) { Some((user, target)) => { - match m3u_load_rewrite_playlist(&app_state.config, target, &user).await { + match m3u_load_rewrite_playlist(&app_state.config, &target, &user).await { Ok(m3u_iter) => { // Convert the iterator into a stream of `Bytes` let content_stream = stream::iter(m3u_iter.map(|line| Ok::(Bytes::from([line.to_string().as_bytes(), b"\n"].concat())))); @@ -81,7 +80,7 @@ async fn m3u_api_stream( let (action_stream_id, stream_ext) = separate_number_and_remainder(stream_req.stream_id); let virtual_id: u32 = try_result_bad_request!(action_stream_id.trim().parse()); - let pli = try_result_bad_request!(m3u_get_item_for_stream_id(virtual_id, &app_state.config, target).await, true, format!("Failed to read m3u item for stream id {}", virtual_id)); + let pli = try_result_bad_request!(m3u_get_item_for_stream_id(virtual_id, &app_state.config, &target).await, true, format!("Failed to read m3u item for stream id {}", virtual_id)); let input = try_option_bad_request!(app_state.config.get_input_by_name(pli.input_name.as_str()), true, format!("Cant find input for target {target_name}, stream_id {virtual_id}")); let cluster = XtreamCluster::try_from(pli.item_type).unwrap_or(XtreamCluster::Live); @@ -99,7 +98,7 @@ async fn m3u_api_stream( } if session.virtual_id == virtual_id && is_seek_request(cluster, req_headers).await { // partial request means we are in reverse proxy mode, seek happened - return force_provider_stream_response(app_state, session, pli.item_type, req_headers, input, &user).await.into_response(); + return force_provider_stream_response(app_state, session, pli.item_type, req_headers, &input, &user).await.into_response(); } session.stream_url.as_str() } else { @@ -118,8 +117,8 @@ async fn m3u_api_stream( provider_id: pli.get_provider_id(), cluster, target_type: TargetType::M3u, - target, - input, + target: &target, + input: &input, user: &user, stream_ext: stream_ext.as_deref(), req_context: context, @@ -136,10 +135,10 @@ async fn m3u_api_stream( let is_hls_request = pli.item_type == PlaylistItemType::LiveHls || pli.item_type == PlaylistItemType::LiveDash || extension == HLS_EXT; // Reverse proxy mode if is_hls_request { - return handle_hls_stream_request(fingerprint, app_state, &user, user_session.as_ref(), &pli.url, pli.virtual_id, input, connection_permission).await.into_response(); + return handle_hls_stream_request(fingerprint, app_state, &user, user_session.as_ref(), &pli.url, pli.virtual_id, &input, connection_permission).await.into_response(); } - stream_response(app_state, &session_key, pli.virtual_id, pli.item_type, session_url, req_headers, input, target, &user, connection_permission).await.into_response() + stream_response(app_state, &session_key, pli.virtual_id, pli.item_type, session_url, req_headers, &input, &target, &user, connection_permission).await.into_response() } async fn m3u_api_resource( @@ -160,7 +159,7 @@ async fn m3u_api_resource( debug!("Target has no m3u playlist {target_name}"); return StatusCode::BAD_REQUEST.into_response(); } - let m3u_item = match m3u_get_item_for_stream_id(m3u_stream_id, &app_state.config, target).await { + let m3u_item = match m3u_get_item_for_stream_id(m3u_stream_id, &app_state.config, &target).await { Ok(item) => item, Err(err) => { error!("Failed to get m3u url: {}", sanitize_sensitive_info(err.to_string().as_str())); diff --git a/backend/src/api/endpoints/user_api.rs b/backend/src/api/endpoints/user_api.rs index 7c355be47..e561b84b6 100644 --- a/backend/src/api/endpoints/user_api.rs +++ b/backend/src/api/endpoints/user_api.rs @@ -1,7 +1,7 @@ use crate::api::api_utils::{get_user_target_by_username, get_username_from_auth_header}; use crate::api::model::app_state::AppState; use crate::auth::validator_user; -use crate::model::{Config, ConfigTarget}; +use crate::model::{AppConfig, ConfigTarget}; use shared::model::{TargetType, XtreamCluster}; use crate::model::PlaylistBouquetDto; use crate::model::PlaylistXtreamCategory; @@ -27,7 +27,7 @@ fn get_categories_from_xtream(categories: Option>) - } -async fn get_categories_from_m3u_playlist(target: &ConfigTarget, config: &Arc) -> Vec { +async fn get_categories_from_m3u_playlist(target: &ConfigTarget, config: &AppConfig) -> Vec { let mut groups = Vec::new(); if let Some((_guard, iter)) = m3u_repository::iter_raw_m3u_playlist(config, target).await { let mut unique_groups = HashSet::new(); @@ -50,9 +50,9 @@ async fn playlist_categories( if user.permission_denied(&app_state) { return axum::http::StatusCode::FORBIDDEN.into_response(); } - let config = &app_state.config; let target_name = &target.name; let xtream_stream = if target.has_output(&TargetType::Xtream) { + let config = &app_state.config.config.load(); let live_categories = get_categories_from_xtream(xtream_get_playlist_categories(config, target_name, XtreamCluster::Live).await); let vod_categories = get_categories_from_xtream(xtream_get_playlist_categories(config, target_name, XtreamCluster::Video).await); let series_categories = get_categories_from_xtream(xtream_get_playlist_categories(config, target_name, XtreamCluster::Series).await); @@ -70,7 +70,7 @@ async fn playlist_categories( }; let m3u_stream = if target.has_output(&TargetType::M3u) { - let live_categories = get_categories_from_m3u_playlist(target, config).await; + let live_categories = get_categories_from_m3u_playlist(&target, &app_state.config).await; stream::iter(vec![ Ok::(Bytes::from(r#"{"live": "#)), Ok::(Bytes::from(serde_json::to_string(&live_categories).unwrap_or("[]".to_string()))), @@ -108,7 +108,8 @@ async fn save_playlist_bouquet( if user.permission_denied(&app_state) { return axum::http::StatusCode::FORBIDDEN.into_response(); } - match save_user_bouquet(&app_state.config, &target.name, &username, &bouquet).await { + let config = &app_state.config.config.load(); + match save_user_bouquet(config, &target.name, &username, &bouquet).await { Ok(()) => { return axum::http::StatusCode::OK.into_response(); } @@ -130,8 +131,9 @@ async fn playlist_bouquet( if user.permission_denied(&app_state) { return axum::http::StatusCode::FORBIDDEN.into_response(); } - let xtream = load_user_bouquet_as_json(&app_state.config, &username, TargetType::Xtream).await; - let m3u = load_user_bouquet_as_json(&app_state.config, &username, TargetType::M3u).await; + let config = &app_state.config.config.load(); + let xtream = load_user_bouquet_as_json(config, &username, TargetType::Xtream).await; + let m3u = load_user_bouquet_as_json(config, &username, TargetType::M3u).await; return axum::response::Response::builder() .status(axum::http::StatusCode::OK) .header("Content-Type", mime::APPLICATION_JSON.to_string()) diff --git a/backend/src/api/endpoints/v1_api.rs b/backend/src/api/endpoints/v1_api.rs index a42119eea..c92e6147a 100644 --- a/backend/src/api/endpoints/v1_api.rs +++ b/backend/src/api/endpoints/v1_api.rs @@ -2,28 +2,26 @@ use crate::api::endpoints::api_playlist_utils::{get_playlist, get_playlist_for_t use crate::api::endpoints::download_api; use crate::api::endpoints::user_api::user_api_register; use crate::api::model::app_state::AppState; -use crate::api::model::config::{ServerConfig, ServerInputConfig, ServerSourceConfig, ServerTargetConfig}; use crate::api::model::request::{PlaylistRequest, PlaylistRequestType}; use crate::auth::create_access_token; use crate::auth::validator_admin; -use shared::error::TuliproxError; -use crate::model::{ConfigTarget, StatusCheck}; -use crate::model::XtreamPlaylistItem; -use crate::model::{Config, ConfigInput, ConfigInputOptions, ConfigSource, InputType}; -use crate::model::{ApiProxyConfig, ApiProxyServerInfo, ProxyUserCredentials, TargetUser}; +use crate::model::{AppConfig, ConfigSource, ConfigTarget, StatusCheck, TargetUser}; +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::request::sanitize_sensitive_info; use crate::{utils, VERSION}; use axum::response::IntoResponse; use log::error; use serde_json::json; +use shared::error::TuliproxError; +use shared::model::{ApiProxyConfigDto, ApiProxyServerInfoDto, ConfigApiDto, ConfigDto, ConfigRenameDto, ConfigSortDto, InputType, TargetOutputDto, TargetUserDto, XtreamPlaylistItem}; +use shared::utils::sanitize_sensitive_info; use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; -use shared::model::ConfigDto; +use crate::api::model::config::{ServerConfig, ServerInputConfig, ServerSourceConfig, ServerTargetConfig}; -fn intern_save_config_api_proxy(backup_dir: &str, api_proxy: &ApiProxyConfig, file_path: &str) -> Option { +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) => { @@ -47,13 +45,13 @@ fn intern_save_config_main(file_path: &str, backup_dir: &str, cfg: &ConfigDto) - async fn save_config_api_proxy_user( axum::extract::State(app_state): axum::extract::State>, - axum::extract::Json(mut users): axum::extract::Json>, + axum::extract::Json(mut users): axum::extract::Json>, ) -> impl axum::response::IntoResponse + Send { let mut usernames = HashSet::new(); let mut tokens = HashSet::new(); for target_user in &mut users { for credential in &mut target_user.credentials { - credential.trim(); + credential.prepare(); if let Err(err) = credential.validate() { return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response(); } @@ -70,10 +68,9 @@ async fn save_config_api_proxy_user( } } - if let Some(old_api_proxy) = app_state.config.t_api_proxy.load().clone() { + if let Some(old_api_proxy) = app_state.config.t_api_proxy.load().clone() { let mut api_proxy = (*old_api_proxy).clone(); - api_proxy.user = users; - api_proxy.user.iter_mut().flat_map(|t| &mut t.credentials).for_each(ProxyUserCredentials::prepare); + api_proxy.user = users.iter().map(TargetUser::from).collect(); let new_api_proxy = Arc::new(api_proxy); app_state.config.t_api_proxy.store(Some(Arc::clone(&new_api_proxy))); @@ -82,8 +79,9 @@ async fn save_config_api_proxy_user( return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response(); } } else { - let backup_dir = app_state.config.backup_dir.as_ref().unwrap().as_str(); - if let Some(err) = intern_save_config_api_proxy(backup_dir, &new_api_proxy, app_state.config.t_api_proxy_file_path.as_str()) { + let config = app_state.config.config.load(); + let backup_dir = config.backup_dir.as_ref().unwrap().as_str(); + if let Some(err) = intern_save_config_api_proxy(backup_dir, &ApiProxyConfigDto::from(&*new_api_proxy), app_state.config.t_api_proxy_file_path.as_str()) { return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response(); } } @@ -97,7 +95,8 @@ async fn save_config_main( ) -> impl axum::response::IntoResponse + Send { if cfg.is_valid() { let file_path = app_state.config.t_config_file_path.as_str(); - let backup_dir = app_state.config.backup_dir.as_ref().unwrap().as_str(); + let config = app_state.config.config.load(); + let backup_dir = config.backup_dir.as_ref().unwrap().as_str(); if let Some(err) = intern_save_config_main(file_path, backup_dir, &cfg) { return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response(); } @@ -109,7 +108,7 @@ async fn save_config_main( 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>, + 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() { @@ -118,13 +117,14 @@ async fn save_config_api_proxy_config( } // TODO wenn hot reload an ist wird doppelt geladen - if let Some(old_api_proxy) = app_state.config.t_api_proxy.load().clone() { + if let Some(old_api_proxy) = app_state.config.t_api_proxy.load().clone() { let mut api_proxy = (*old_api_proxy).clone(); - api_proxy.server = req_api_proxy; + api_proxy.server = req_api_proxy.iter().map(Into::into).collect(); let new_api_proxy = Arc::new(api_proxy); app_state.config.t_api_proxy.store(Some(Arc::clone(&new_api_proxy))); - let backup_dir = app_state.config.backup_dir.as_ref().unwrap().as_str(); - if let Some(err) = intern_save_config_api_proxy(backup_dir, new_api_proxy.as_ref(), app_state.config.t_api_proxy_file_path.as_str()) { + let config = app_state.config.config.load(); + let backup_dir = config.backup_dir.as_ref().unwrap().as_str(); + if let Some(err) = intern_save_config_api_proxy(backup_dir, &ApiProxyConfigDto::from(new_api_proxy.as_ref()), app_state.config.t_api_proxy_file_path.as_str()) { return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response(); } } @@ -136,7 +136,7 @@ async fn playlist_update( 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.config.sources.validate_targets(user_targets.as_ref()); + let process_targets = app_state.config.sources.load().validate_targets(user_targets.as_ref()); match process_targets { Ok(valid_targets) => { tokio::spawn(playlist::exec_processing(Arc::clone(&app_state.http_client), Arc::clone(&app_state.config), Arc::new(valid_targets))); @@ -192,17 +192,18 @@ 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.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), app_state.config.get_input_by_id(source_id), &app_state.config).await.into_response() + get_playlist(Arc::clone(&app_state.http_client), app_state.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.config.get_target_by_id(source_id), &app_state.config).await.into_response() + get_playlist_for_target(app_state.config.get_target_by_id(source_id).as_deref(), &app_state.config).await.into_response() } else { (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid target"}))).into_response() } @@ -210,7 +211,7 @@ async fn playlist_content( 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), Some(&input), &app_state.config).await.into_response() + get_playlist(Arc::clone(&app_state.http_client), Some(&input), &config).await.into_response() } else { (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url"}))).into_response() } @@ -218,7 +219,7 @@ async fn playlist_content( 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), Some(&input), &app_state.config).await.into_response() + get_playlist(Arc::clone(&app_state.http_client), Some(&input), &config).await.into_response() } else { (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url"}))).into_response() } @@ -232,7 +233,8 @@ async fn playlist_webplayer( axum::extract::Json(playlist_item): axum::extract::Json, ) -> impl axum::response::IntoResponse + Send { let access_token = create_access_token(&app_state.config.t_access_token_secret, 5); - let server_name = app_state.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 config = app_state.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.config.get_server_info(server_name); let base_url = server_info.get_base_url(); format!("{base_url}/token/{access_token}/{target_id}/{}/{}", playlist_item.xtream_cluster.as_stream_type(), playlist_item.virtual_id).into_response() @@ -241,7 +243,7 @@ async fn playlist_webplayer( async fn config( axum::extract::State(app_state): axum::extract::State>, ) -> impl axum::response::IntoResponse + Send { - let map_input = |i: &ConfigInput| ServerInputConfig { + let map_input = |i: &Arc| ServerInputConfig { id: i.id, name: i.name.clone(), input_type: i.input_type, @@ -252,18 +254,21 @@ async fn config( enabled: i.enabled, }; - let map_target = |t: &ConfigTarget| ServerTargetConfig { - id: t.id, - enabled: t.enabled, - name: t.name.clone(), - options: t.options.clone(), - sort: t.sort.clone(), - filter: t.filter.clone(), - output: t.output.clone(), - rename: t.rename.clone(), - mapping: t.mapping.clone(), - processing_order: t.processing_order, - watch: t.watch.clone(), + let map_target = |t: &Arc| { + let mapping = t.mapping.load(); + ServerTargetConfig { + id: t.id, + enabled: t.enabled, + name: t.name.clone(), + options: t.options.clone(), + sort: t.sort.as_ref().map(ConfigSortDto::from), + filter: t.filter.to_string(), + output: t.output.iter().map(TargetOutputDto::from).collect(), + rename: t.rename.as_ref().map(|l| l.iter().map(ConfigRenameDto::from).collect()), + mapping: mapping.as_ref().map(|m| m.iter().map(|m| m.id.to_string()).collect()), + processing_order: t.processing_order, + watch: t.watch.as_ref().map(|w| w.iter().map(std::string::ToString::to_string).collect()), + } }; let map_source = |s: &ConfigSource| ServerSourceConfig { @@ -271,31 +276,35 @@ async fn config( targets: s.targets.iter().map(map_target).collect(), }; - let map_config = |config: &Config| ServerConfig { - api: config.api.clone(), - threads: config.threads, - working_dir: config.working_dir.clone(), - backup_dir: config.backup_dir.clone(), - user_config_dir: config.user_config_dir.clone(), - log: config.log.clone(), - update_on_boot: config.update_on_boot, - web_ui: config.web_ui.clone(), - schedules: config.schedules.clone(), - reverse_proxy: config.reverse_proxy.clone(), - messaging: config.messaging.clone(), - video: config.video.clone(), - sources: config.sources.sources.iter().map(map_source).collect(), - proxy: config.proxy.clone(), - ipcheck: config.ipcheck.clone(), - api_proxy: utils::read_api_proxy(&app_state.config, false), + let map_config = |app_config: &AppConfig| { + let sources = app_config.sources.load(); + let config = app_config.config.load(); + ServerConfig { + api: ConfigApiDto::from(&config.api), + threads: config.threads, + working_dir: config.working_dir.clone(), + backup_dir: config.backup_dir.clone(), + user_config_dir: config.user_config_dir.clone(), + log: config.log.as_ref().map(Into::into), + update_on_boot: config.update_on_boot, + web_ui: config.web_ui.as_ref().map(Into::into), + schedules: config.schedules.as_ref().map(|l| l.iter().map(Into::into).collect()), + reverse_proxy: config.reverse_proxy.as_ref().map(Into::into), + messaging: config.messaging.as_ref().map(Into::into), + video: config.video.as_ref().map(Into::into), + sources: sources.sources.iter().map(map_source).collect(), + proxy: config.proxy.as_ref().map(Into::into), + ipcheck: config.ipcheck.as_ref().map(Into::into), + api_proxy: utils::read_api_proxy_dto(&app_state.config, false), + } }; let mut result = match utils::read_config(app_state.config.t_config_path.as_str(), - app_state.config.t_config_file_path.as_str(), - app_state.config.t_sources_file_path.as_str(), - app_state.config.t_api_proxy_file_path.as_str(), - Some(app_state.config.t_mapping_file_path.to_string()), - false) { + app_state.config.t_config_file_path.as_str(), + app_state.config.t_sources_file_path.as_str(), + app_state.config.t_api_proxy_file_path.as_str(), + Some(app_state.config.t_mapping_file_path.to_string()), + false) { Ok(mut cfg) => { let _ = cfg.prepare(false); map_config(&cfg) @@ -306,7 +315,7 @@ async fn config( // if we didn't read it from file then we should use it from app_state if result.api_proxy.is_none() { let value = app_state.config.t_api_proxy.load(); - result.api_proxy = value.as_ref().map(|a| a.as_ref().clone()); + result.api_proxy = value.as_ref().map(|a| ApiProxyConfigDto::from(a.as_ref())); } axum::response::Json(result).into_response() @@ -322,7 +331,8 @@ struct IpCheck { } async fn create_ipinfo_check(app_state: &Arc) -> Option<(Option, Option)> { - if let Some(ipcheck) = app_state.config.ipcheck.as_ref() { + let config = app_state.config.config.load(); + if let Some(ipcheck) = config.ipcheck.as_ref() { if let Ok(check) = get_ips(&app_state.http_client, ipcheck).await { return Some(check); } @@ -371,11 +381,11 @@ async fn ipinfo(axum::extract::State(app_state): axum::extract::State axum::response::Response::builder().status(axum::http::StatusCode::OK) .header(axum::http::header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()).body(json).unwrap().into_response(), Err(_) => axum::Json(ipcheck).into_response(), - } + }; } axum::http::StatusCode::BAD_REQUEST.into_response() } @@ -394,7 +404,8 @@ pub fn v1_api_register(web_auth_enabled: bool, app_state: Arc, web_ui_ .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)); - if app_state.config.ipcheck.is_some() { + let config = app_state.config.config.load(); + if config.ipcheck.is_some() { router = router.route("/ipinfo", axum::routing::get(ipinfo)); } if web_auth_enabled { @@ -402,7 +413,7 @@ pub fn v1_api_register(web_auth_enabled: bool, app_state: Arc, web_ui_ } let mut base_router = axum::Router::new(); - if app_state.config.web_ui.as_ref().is_none_or(|c| c.user_ui_enabled) { + if config.web_ui.as_ref().is_none_or(|c| c.user_ui_enabled) { base_router = base_router.merge(user_api_register(app_state)); } base_router.nest(&format!("{web_ui_path}/api/v1"), router) diff --git a/backend/src/api/endpoints/web_index.rs b/backend/src/api/endpoints/web_index.rs index 74bc8a5b8..d5c065ad4 100644 --- a/backend/src/api/endpoints/web_index.rs +++ b/backend/src/api/endpoints/web_index.rs @@ -18,7 +18,8 @@ async fn token( axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(mut req): axum::extract::Json, ) -> impl axum::response::IntoResponse + Send { - match &app_state.config.web_ui.as_ref().and_then(|c| c.auth.as_ref()) { + let config = &app_state.config.config.load(); + match config.web_ui.as_ref().and_then(|c| c.auth.as_ref()) { None => no_web_auth_token().into_response(), Some(web_auth) => { if !web_auth.enabled { @@ -56,7 +57,8 @@ async fn token_refresh( AuthBearer(token): AuthBearer, axum::extract::State(app_state): axum::extract::State>, ) -> impl axum::response::IntoResponse + Send { - match &app_state.config.web_ui.as_ref().and_then(|c| c.auth.as_ref()) { + let config = &app_state.config.config.load(); + match &config.web_ui.as_ref().and_then(|c| c.auth.as_ref()) { None => no_web_auth_token().into_response(), Some(web_auth) => { if !web_auth.enabled { @@ -83,8 +85,9 @@ async fn token_refresh( async fn index( axum::extract::State(app_state): axum::extract::State>, ) -> impl axum::response::IntoResponse + Send { - let path: PathBuf = [&app_state.config.api.web_root, "index.html"].iter().collect(); - if let Some(web_ui_path) = &app_state.config.web_ui.as_ref().and_then(|c| c.path.as_ref()) { + let config = &app_state.config.config.load(); + let path: PathBuf = [&config.api.web_root, "index.html"].iter().collect(); + if let Some(web_ui_path) = &config.web_ui.as_ref().and_then(|c| c.path.as_ref()) { match tokio::fs::read_to_string(&path).await { Ok(content) => { let mut new_content = CONSTANTS.re_base_href.replace_all(&content, |caps: ®ex::Captures| { @@ -112,8 +115,9 @@ async fn index( async fn index_config( axum::extract::State(app_state): axum::extract::State>, ) -> impl axum::response::IntoResponse + Send { - let path: PathBuf = [&app_state.config.api.web_root, "config.json"].iter().collect(); - if let Some(web_ui_path) = &app_state.config.web_ui.as_ref().and_then(|c| c.path.as_ref()) { + let config = &app_state.config.config.load(); + let path: PathBuf = [&config.api.web_root, "config.json"].iter().collect(); + if let Some(web_ui_path) = &config.web_ui.as_ref().and_then(|c| c.path.as_ref()) { match tokio::fs::read_to_string(&path).await { Ok(content) => { if let Ok(mut json_data) = serde_json::from_str::(&content) { diff --git a/backend/src/api/endpoints/xmltv_api.rs b/backend/src/api/endpoints/xmltv_api.rs index 937125274..1e0702921 100644 --- a/backend/src/api/endpoints/xmltv_api.rs +++ b/backend/src/api/endpoints/xmltv_api.rs @@ -192,7 +192,8 @@ async fn xmltv_api( return axum::http::StatusCode::FORBIDDEN.into_response(); } - let Some(epg_path) = get_epg_path_for_target(&app_state.config, target) else { + let config = &app_state.config.config.load(); + let Some(epg_path) = get_epg_path_for_target(config, &target) else { // No epg configured, No processing or timeshift, epg can't be mapped to the channels. // we do not deliver epg return get_empty_epg_response().into_response(); diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index 1b1f5202f..625527b73 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -9,7 +9,7 @@ use crate::api::model::app_state::AppState; use crate::api::model::request::UserApiRequest; use crate::api::model::streams::provider_stream::{create_custom_video_stream_response, CustomVideoStreamType}; use crate::api::model::xtream::XtreamAuthorizationResponse; -use crate::model::{get_backdrop_path_value, ConfigTarget, XtreamPlaylistItem}; +use crate::model::{AppConfig, ConfigTarget}; use crate::model::{Config, ConfigInput}; use crate::model::{ProxyUserCredentials}; use crate::repository::playlist_repository::get_target_id_mapping; @@ -18,12 +18,9 @@ use crate::repository::{storage_const, user_repository, xtream_repository}; use shared::error::create_tuliprox_error_result; use shared::error::info_err; use shared::error::{str_to_io_error, TuliproxError, TuliproxErrorKind}; -use crate::utils::{generate_playlist_uuid, hex_encode}; -use crate::utils::get_u32_from_serde_value; -use crate::utils::request::{extract_extension_from_url, sanitize_sensitive_info}; use crate::utils::trace_if_enabled; use crate::utils::xtream::create_vod_info_from_item; -use shared::utils::HLS_EXT; +use shared::utils::{extract_extension_from_url, generate_playlist_uuid, get_u32_from_serde_value, hex_encode, sanitize_sensitive_info, HLS_EXT}; use crate::utils::{request, xtream}; use crate::auth::Fingerprint; use axum::http::{HeaderMap, StatusCode}; @@ -40,7 +37,7 @@ use std::fmt::{Display, Formatter}; use std::path::Path; use std::str::FromStr; use std::sync::Arc; -use shared::model::{PlaylistItemType, XtreamCluster, FieldGetAccessor, PlaylistEntry, TargetType, UserConnectionPermission, ProxyType}; +use shared::model::{PlaylistItemType, XtreamCluster, FieldGetAccessor, PlaylistEntry, TargetType, UserConnectionPermission, ProxyType, get_backdrop_path_value, XtreamPlaylistItem}; #[derive(Serialize, Deserialize, Debug, Copy, Clone, Eq, PartialEq)] pub enum ApiStreamContext { @@ -171,7 +168,7 @@ pub(in crate::api) fn get_xtream_player_api_stream_url( async fn get_user_info(user: &ProxyUserCredentials, app_state: &AppState) -> XtreamAuthorizationResponse { let server_info = app_state.config.get_user_server_info(user); let active_connections = app_state.get_active_connections_for_user(&user.username).await; - XtreamAuthorizationResponse::new(&server_info, user, active_connections, app_state.config.user_access_control) + XtreamAuthorizationResponse::new(&server_info, user, active_connections, app_state.config.config.load().user_access_control) } async fn xtream_player_api_stream( @@ -194,7 +191,7 @@ async fn xtream_player_api_stream( let (action_stream_id, stream_ext) = separate_number_and_remainder(stream_req.stream_id); let virtual_id: u32 = try_result_bad_request!(action_stream_id.trim().parse()); - let (pli, mapping) = try_result_bad_request!(xtream_repository::xtream_get_item_for_stream_id(virtual_id, &app_state.config, target, None), true, format!("Failed to read xtream item for stream id {}", virtual_id)); + let (pli, mapping) = try_result_bad_request!(xtream_repository::xtream_get_item_for_stream_id(virtual_id, &app_state.config, &target, None), true, format!("Failed to read xtream item for stream id {}", virtual_id)); let input = try_option_bad_request!(app_state.config.get_input_by_name(pli.input_name.as_str()), true, format!("Cant find input for target {target_name}, context {}, stream_id {virtual_id}", stream_req.context)); let cluster = pli.xtream_cluster; @@ -214,7 +211,7 @@ async fn xtream_player_api_stream( if session.virtual_id == virtual_id && is_seek_request(cluster, req_headers).await { // partial request means we are in reverse proxy mode, seek happened - return force_provider_stream_response(app_state, session, item_type, req_headers, input, &user).await.into_response(); + return force_provider_stream_response(app_state, session, item_type, req_headers, &input, &user).await.into_response(); } session.stream_url.as_str() @@ -234,8 +231,8 @@ async fn xtream_player_api_stream( provider_id: Some(mapping.provider_id), cluster, target_type: TargetType::Xtream, - target, - input, + target: &target, + input: &input, user: &user, stream_ext: stream_ext.as_deref(), req_context: context, @@ -254,16 +251,16 @@ async fn xtream_player_api_stream( format!("{}/{}{extension}", stream_req.action_path, pli.provider_id) }; - let stream_url = try_option_bad_request!(get_xtream_player_api_stream_url(input, stream_req.context, &query_path, session_url), + let stream_url = try_option_bad_request!(get_xtream_player_api_stream_url(&input, stream_req.context, &query_path, session_url), true, format!("Cant find stream url for target {target_name}, context {}, stream_id {virtual_id}", stream_req.context)); let is_hls_request = item_type == PlaylistItemType::LiveHls || item_type == PlaylistItemType::LiveDash || extension == HLS_EXT; // Reverse proxy mode if is_hls_request { - return handle_hls_stream_request(fingerprint, app_state, &user, user_session.as_ref(), &stream_url, pli.virtual_id, input, connection_permission).await.into_response(); + return handle_hls_stream_request(fingerprint, app_state, &user, user_session.as_ref(), &stream_url, pli.virtual_id, &input, connection_permission).await.into_response(); } - stream_response(app_state, session_key.as_str(), pli.virtual_id, item_type, &stream_url, req_headers, input, target, &user, connection_permission).await.into_response() + stream_response(app_state, session_key.as_str(), pli.virtual_id, item_type, &stream_url, req_headers, &input, &target, &user, connection_permission).await.into_response() } // Used by webui @@ -282,14 +279,15 @@ async fn xtream_player_api_stream_with_token( } let (action_stream_id, stream_ext) = separate_number_and_remainder(stream_req.stream_id); let virtual_id: u32 = try_result_bad_request!(action_stream_id.trim().parse()); - let (pli, _mapping) = try_result_bad_request!(xtream_repository::xtream_get_item_for_stream_id(virtual_id, &app_state.config, target, None), true, format!("Failed to read xtream item for stream id {}", virtual_id)); + let (pli, _mapping) = try_result_bad_request!(xtream_repository::xtream_get_item_for_stream_id(virtual_id, &app_state.config, &target, None), true, format!("Failed to read xtream item for stream id {}", virtual_id)); let input = try_option_bad_request!(app_state.config.get_input_by_name(pli.input_name.as_str()), true, format!("Cant find input for target {target_name}, context {}, stream_id {virtual_id}", stream_req.context)); let session_key = format!("{fingerprint}{virtual_id}"); let is_hls_request = pli.item_type == PlaylistItemType::LiveHls || stream_ext.as_deref() == Some(HLS_EXT); - let server = app_state.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 config = app_state.config.config.load(); + let server = 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 user = ProxyUserCredentials { username: "api_user".to_string(), @@ -310,7 +308,7 @@ async fn xtream_player_api_stream_with_token( // Reverse proxy mode if is_hls_request { - return handle_hls_stream_request(fingerprint, app_state, &user, None, &pli.url, pli.virtual_id, input, UserConnectionPermission::Allowed).await.into_response(); + return handle_hls_stream_request(fingerprint, app_state, &user, None, &pli.url, pli.virtual_id, &input, UserConnectionPermission::Allowed).await.into_response(); } let extension = stream_ext.unwrap_or_else( @@ -322,13 +320,13 @@ async fn xtream_player_api_stream_with_token( format!("{}/{}{extension}", stream_req.action_path, pli.provider_id) }; - let stream_url = try_option_bad_request!(get_xtream_player_api_stream_url(input, + let stream_url = try_option_bad_request!(get_xtream_player_api_stream_url(&input, stream_req.context, &query_path, pli.url.as_str()), true, format!("Cant find stream url for target {target_name}, context {}, stream_id {virtual_id}", stream_req.context)); trace_if_enabled!("Streaming stream request from {}", sanitize_sensitive_info(&stream_url)); - stream_response(app_state, session_key.as_str(), pli.virtual_id, pli.item_type, &stream_url, req_headers, input, target, &user, UserConnectionPermission::Allowed).await.into_response() + stream_response(app_state, session_key.as_str(), pli.virtual_id, pli.item_type, &stream_url, req_headers, &input, &target, &user, UserConnectionPermission::Allowed).await.into_response() } else { axum::http::StatusCode::BAD_REQUEST.into_response() } @@ -357,7 +355,7 @@ fn get_doc_resource_field_value<'a>(field: &'a str, doc: Option<&'a Value>) -> O None } -fn xtream_get_info_resource_url<'a>(config: &'a Config, pli: &'a XtreamPlaylistItem, target: &'a ConfigTarget, resource: &'a str) -> Result>, serde_json::Error> { +fn xtream_get_info_resource_url<'a>(config: &'a AppConfig, pli: &'a XtreamPlaylistItem, target: &'a ConfigTarget, resource: &'a str) -> Result>, serde_json::Error> { let info_content = match pli.xtream_cluster { XtreamCluster::Video => { xtream_repository::xtream_load_vod_info(config, target.name.as_str(), pli.get_virtual_id()) @@ -432,7 +430,7 @@ fn get_season_info_doc(doc: &Vec, season_id: u32) -> Option<&Value> { } -fn xtream_get_season_resource_url<'a>(config: &'a Config, pli: &'a XtreamPlaylistItem, target: &'a ConfigTarget, resource: &'a str) -> Result>, serde_json::Error> { +fn xtream_get_season_resource_url<'a>(config: &'a AppConfig, pli: &'a XtreamPlaylistItem, target: &'a ConfigTarget, resource: &'a str) -> Result>, serde_json::Error> { let info_content = match pli.xtream_cluster { XtreamCluster::Series => { xtream_repository::xtream_load_series_info(config, target.name.as_str(), pli.get_virtual_id()) @@ -477,11 +475,11 @@ async fn xtream_player_api_resource( } let virtual_id: u32 = try_result_bad_request!(resource_req.stream_id.trim().parse()); let resource = resource_req.action_path.trim(); - let (pli, _) = try_result_bad_request!(xtream_repository::xtream_get_item_for_stream_id(virtual_id, &app_state.config, target, None), true, format!("Failed to read xtream item for stream id {}", virtual_id)); + let (pli, _) = try_result_bad_request!(xtream_repository::xtream_get_item_for_stream_id(virtual_id, &app_state.config, &target, None), true, format!("Failed to read xtream item for stream id {}", virtual_id)); let stream_url = if resource.starts_with(crate::model::XC_INFO_RESOURCE_PREFIX) { - try_result_bad_request!(xtream_get_info_resource_url(&app_state.config, &pli, target, resource)) + try_result_bad_request!(xtream_get_info_resource_url(&app_state.config, &pli, &target, resource)) } else if resource.starts_with(crate::model::XC_SEASON_RESOURCE_PREFIX) { - try_result_bad_request!(xtream_get_season_resource_url(&app_state.config, &pli, target, resource)) + try_result_bad_request!(xtream_get_season_resource_url(&app_state.config, &pli, &target, resource)) } else { pli.get_field(resource) }; @@ -618,11 +616,11 @@ async fn xtream_get_stream_info_response(app_state: &AppState, user: &ProxyUserC if pli.provider_id > 0 { let input_name = &pli.input_name; if let Some(input) = app_state.config.get_input_by_name(input_name.as_str()) { - if let Some(info_url) = xtream::get_xtream_player_api_info_url(input, cluster, pli.provider_id) { + if let Some(info_url) = xtream::get_xtream_player_api_info_url(&input, cluster, pli.provider_id) { // Redirect is only possible for live streams, vod and series info needs to be modified if user.proxy == ProxyType::Redirect && cluster == XtreamCluster::Live { return redirect(&info_url).into_response(); - } else if let Ok(content) = xtream::get_xtream_stream_info(Arc::clone(&app_state.http_client), &app_state.config, user, input, target, &pli, info_url.as_str(), cluster).await { + } else if let Ok(content) = xtream::get_xtream_stream_info(Arc::clone(&app_state.http_client), &app_state.config, user, &input, target, &pli, info_url.as_str(), cluster).await { return axum::response::Response::builder() .status(StatusCode::OK) .header(axum::http::header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()) @@ -672,7 +670,7 @@ async fn xtream_get_short_epg(app_state: &AppState, user: &ProxyUserCredentials, if pli.provider_id > 0 { let input_name = &pli.input_name; if let Some(input) = app_state.config.get_input_by_name(input_name.as_str()) { - if let Some(action_url) = xtream::get_xtream_player_api_action_url(input, crate::model::XC_ACTION_GET_SHORT_EPG) { + if let Some(action_url) = xtream::get_xtream_player_api_action_url(&input, crate::model::XC_ACTION_GET_SHORT_EPG) { let mut info_url = format!("{action_url}&{}={}", crate::model::XC_TAG_STREAM_ID, pli.provider_id); if !(limit.is_empty() || limit.eq("0")) { info_url = format!("{info_url}&limit={limit}"); @@ -682,7 +680,7 @@ async fn xtream_get_short_epg(app_state: &AppState, user: &ProxyUserCredentials, } // TODO serve epg from own db - return match request::download_text_content(Arc::clone(&app_state.http_client), input, info_url.as_str(), None).await { + return match request::download_text_content(Arc::clone(&app_state.http_client), &input, info_url.as_str(), None).await { Ok(content) => (axum::http::StatusCode::OK, axum::Json(content)).into_response(), Err(err) => { error!("Failed to download epg {}", sanitize_sensitive_info(err.to_string().as_str())); @@ -731,12 +729,13 @@ async fn xtream_get_catchup_response(app_state: &AppState, target: &ConfigTarget let virtual_id: u32 = try_result_bad_request!(FromStr::from_str(stream_id)); let (pli, _) = try_result_bad_request!(xtream_repository::xtream_get_item_for_stream_id(virtual_id, &app_state.config, target, Some(XtreamCluster::Live))); let input = try_option_bad_request!(app_state.config.get_input_by_name(pli.input_name.as_str())); - let info_url = try_option_bad_request!(xtream::get_xtream_player_api_action_url(input, crate::model::XC_ACTION_GET_CATCHUP_TABLE) + let info_url = try_option_bad_request!(xtream::get_xtream_player_api_action_url(&input, crate::model::XC_ACTION_GET_CATCHUP_TABLE) .map(|action_url| format!("{action_url}&{}={}&start={start}&end={end}", crate::model::XC_TAG_STREAM_ID, pli.provider_id))); - let content = try_result_bad_request!(xtream::get_xtream_stream_info_content(Arc::clone(&app_state.http_client), info_url.as_str(), input).await); + let content = try_result_bad_request!(xtream::get_xtream_stream_info_content(Arc::clone(&app_state.http_client), info_url.as_str(), &input).await); let mut doc: Map = try_result_bad_request!(serde_json::from_str(&content)); let epg_listings = try_option_bad_request!(doc.get_mut(crate::model::XC_TAG_EPG_LISTINGS).and_then(Value::as_array_mut)); - let target_path = try_option_bad_request!(get_target_storage_path(&app_state.config, target.name.as_str())); + let config = &app_state.config.config.load(); + let target_path = try_option_bad_request!(get_target_storage_path(config, target.name.as_str())); let (mut target_id_mapping, file_lock) = get_target_id_mapping(&app_state.config, &target_path).await; for epg_list_item in epg_listings.iter_mut().filter_map(Value::as_object_mut) { // TODO epg_id @@ -813,18 +812,18 @@ async fn xtream_player_api( return axum::response::Json(get_user_info(&user, app_state).await).into_response(); } crate::model::XC_ACTION_GET_SERIES_INFO => { - skip_json_response_if_flag_set!(skip_series, xtream_get_stream_info_response(app_state, &user, target, api_req.series_id.trim(), XtreamCluster::Series).await); + skip_json_response_if_flag_set!(skip_series, xtream_get_stream_info_response(app_state, &user, &target, api_req.series_id.trim(), XtreamCluster::Series).await); } crate::model::XC_ACTION_GET_VOD_INFO => { - skip_json_response_if_flag_set!(skip_vod, xtream_get_stream_info_response(app_state, &user, target, api_req.vod_id.trim(), XtreamCluster::Video).await); + skip_json_response_if_flag_set!(skip_vod, xtream_get_stream_info_response(app_state, &user, &target, api_req.vod_id.trim(), XtreamCluster::Video).await); } crate::model::XC_ACTION_GET_EPG | crate::model::XC_ACTION_GET_SHORT_EPG => { return xtream_get_short_epg( - app_state, &user, target, api_req.stream_id.trim(), api_req.limit.trim(), + app_state, &user, &target, api_req.stream_id.trim(), api_req.limit.trim(), ).await.into_response(); } crate::model::XC_ACTION_GET_CATCHUP_TABLE => { - skip_json_response_if_flag_set!(skip_live, xtream_get_catchup_response(app_state, target, api_req.stream_id.trim(), api_req.start.trim(), api_req.end.trim()).await); + skip_json_response_if_flag_set!(skip_live, xtream_get_catchup_response(app_state, &target, api_req.stream_id.trim(), api_req.start.trim(), api_req.end.trim()).await); } _ => {} } @@ -832,18 +831,18 @@ async fn xtream_player_api( let category_id = api_req.category_id.trim().parse::().ok(); // Handle general content actions if let Some(response) = xtream_player_api_handle_content_action( - &app_state.config, &target.name, action, category_id, &user, + &app_state.config.config.load(), &target.name, action, category_id, &user, ).await { return response.into_response(); } let result = match action { crate::model::XC_ACTION_GET_LIVE_STREAMS => - skip_flag_optional!(skip_live, xtream_repository::xtream_load_rewrite_playlist(XtreamCluster::Live, &app_state.config, target, category_id, &user).await), + skip_flag_optional!(skip_live, xtream_repository::xtream_load_rewrite_playlist(XtreamCluster::Live, &app_state.config, &target, category_id, &user).await), crate::model::XC_ACTION_GET_VOD_STREAMS => - skip_flag_optional!(skip_vod, xtream_repository::xtream_load_rewrite_playlist(XtreamCluster::Video, &app_state.config, target, category_id, &user).await), + skip_flag_optional!(skip_vod, xtream_repository::xtream_load_rewrite_playlist(XtreamCluster::Video, &app_state.config, &target, category_id, &user).await), crate::model::XC_ACTION_GET_SERIES => - skip_flag_optional!(skip_series, xtream_repository::xtream_load_rewrite_playlist(XtreamCluster::Series, &app_state.config, target, category_id, &user).await), + skip_flag_optional!(skip_series, xtream_repository::xtream_load_rewrite_playlist(XtreamCluster::Series, &app_state.config, &target, category_id, &user).await), _ => Some(Err(info_err!(format!("Cant find action: {action} for target: {}", &target.name)) )), }; diff --git a/backend/src/api/main_api.rs b/backend/src/api/main_api.rs index 253ca0bb1..356c07c2b 100644 --- a/backend/src/api/main_api.rs +++ b/backend/src/api/main_api.rs @@ -11,7 +11,7 @@ use crate::api::model::app_state::{AppState, HdHomerunAppState}; use crate::api::model::download::DownloadQueue; use crate::api::model::streams::shared_stream_manager::SharedStreamManager; use crate::api::scheduler::start_scheduler; -use crate::model::{Config, ProcessTargets, RateLimitConfig, ScheduleConfig}; +use crate::model::{AppConfig, Config, ProcessTargets, RateLimitConfig, ScheduleConfig}; use crate::model::{Healthcheck}; use crate::processing::processor::playlist; use crate::tools::lru_cache::LRUResourceCache; @@ -54,9 +54,10 @@ async fn healthcheck() -> impl axum::response::IntoResponse { axum::Json(create_healthcheck()) } -async fn create_shared_data(cfg: &Arc) -> AppState { - let lru_cache = cfg.reverse_proxy.as_ref().and_then(|r| r.cache.as_ref()).and_then(|c| if c.enabled { - Some(Mutex::new(LRUResourceCache::new(c.t_size, &PathBuf::from(c.dir.as_ref().unwrap())))) +async fn create_shared_data(app_config: &Arc) -> AppState { + let config = app_config.config.load(); + let lru_cache = config.reverse_proxy.as_ref().and_then(|r| r.cache.as_ref()).and_then(|c| if c.enabled { + Some(Mutex::new(LRUResourceCache::new(c.size, &PathBuf::from(c.dir.as_str())))) } else { None }); let cache = Arc::new(lru_cache); let cache_scanner = Arc::clone(&cache); @@ -69,18 +70,18 @@ async fn create_shared_data(cfg: &Arc) -> AppState { } }); - let active_users = Arc::new(ActiveUserManager::new(cfg)); - let active_provider = Arc::new(ActiveProviderManager::new(cfg).await); + let active_users = Arc::new(ActiveUserManager::new(&config)); + let active_provider = Arc::new(ActiveProviderManager::new(app_config).await); - let mut builder = create_client(cfg).http1_only(); // because of RAII connection dropping - if cfg.connect_timeout_secs > 0 { - builder = builder.connect_timeout(Duration::from_secs(u64::from(cfg.connect_timeout_secs))); + let mut builder = create_client(app_config).http1_only(); // because of RAII connection dropping + if config.connect_timeout_secs > 0 { + builder = builder.connect_timeout(Duration::from_secs(u64::from(config.connect_timeout_secs))); } let client = builder.build().unwrap_or_else(|_| Client::new()); AppState { - config: Arc::clone(cfg), + config: Arc::clone(app_config), http_client: Arc::new(client), downloads: Arc::new(DownloadQueue::new()), cache, @@ -90,8 +91,9 @@ async fn create_shared_data(cfg: &Arc) -> AppState { } } -fn exec_update_on_boot(client: Arc, cfg: &Arc, targets: &Arc) { - if cfg.update_on_boot { +fn exec_update_on_boot(client: Arc, cfg: &Arc, targets: &Arc) { + let config = cfg.config.load(); + if config.update_on_boot { let cfg_clone = Arc::clone(cfg); let targets_clone = Arc::clone(targets); tokio::spawn( @@ -101,8 +103,9 @@ fn exec_update_on_boot(client: Arc, cfg: &Arc, targets: } -fn get_process_targets(cfg: &Arc, process_targets: &Arc, exec_targets: Option<&Vec>) -> Arc { - if let Ok(user_targets) = cfg.sources.validate_targets(exec_targets) { +fn get_process_targets(cfg: &Arc, process_targets: &Arc, exec_targets: Option<&Vec>) -> Arc { + let sources = cfg.sources.load(); + if let Ok(user_targets) = sources.validate_targets(exec_targets) { if user_targets.enabled { if !process_targets.enabled { return Arc::new(user_targets); @@ -126,8 +129,9 @@ fn get_process_targets(cfg: &Arc, process_targets: &Arc, Arc::clone(process_targets) } -fn exec_scheduler(client: &Arc, cfg: &Arc, targets: &Arc) { - let schedules: Vec = if let Some(schedules) = &cfg.schedules { +fn exec_scheduler(client: &Arc, cfg: &Arc, targets: &Arc) { + let config = cfg.config.load(); + let schedules: Vec = if let Some(schedules) = &config.schedules { schedules.clone() } else { vec![] @@ -168,9 +172,10 @@ fn create_compression_layer() -> tower_http::compression::CompressionLayer { .zstd(true) } -fn start_hdhomerun(cfg: &Arc, app_state: &Arc, infos: &mut Vec) { - let host = cfg.api.host.to_string(); - let guard = cfg.t_hdhomerun.load(); +fn start_hdhomerun(app_config: &Arc, app_state: &Arc, infos: &mut Vec) { + let config = app_config.config.load(); + let host = config.api.host.to_string(); + let guard = app_config.t_hdhomerun.load(); if let Some(hdhomerun) = &*guard { if hdhomerun.enabled { for device in &hdhomerun.devices { @@ -214,8 +219,9 @@ fn start_hdhomerun(cfg: &Arc, app_state: &Arc, infos: &mut Vec // next.run(request).await // } -pub async fn start_server(cfg: Arc, targets: Arc) -> futures::io::Result<()> { +pub async fn start_server(app_config: Arc, targets: Arc) -> futures::io::Result<()> { let mut infos = Vec::new(); + let cfg = app_config.config.load(); let host = cfg.api.host.to_string(); let port = cfg.api.port; let web_ui_enabled = cfg.web_ui.as_ref().is_some_and(|c| c.enabled); @@ -226,12 +232,12 @@ pub async fn start_server(cfg: Arc, targets: Arc) -> fut if web_ui_enabled { infos.push(format!("Web root: {}", web_dir_path.display())); } - let app_shared_data = create_shared_data(&cfg).await; + let app_shared_data = create_shared_data(&app_config).await; let app_state = Arc::new(app_shared_data); let shared_data = Arc::clone(&app_state); - exec_scheduler(&Arc::clone(&shared_data.http_client), &cfg, &targets); - exec_update_on_boot(Arc::clone(&shared_data.http_client), &cfg, &targets); + exec_scheduler(&Arc::clone(&shared_data.http_client), &app_config, &targets); + exec_update_on_boot(Arc::clone(&shared_data.http_client), &app_config, &targets); if cfg.config_hot_reload { if let Err(err) = exec_config_watch(&app_state).await { @@ -241,8 +247,8 @@ pub async fn start_server(cfg: Arc, targets: Arc) -> fut let web_auth_enabled = is_web_auth_enabled(&cfg, web_ui_enabled); - if cfg.t_api_proxy.load().is_some() { - start_hdhomerun(&cfg, &app_state, &mut infos); + if app_config.t_api_proxy.load().is_some() { + start_hdhomerun(&app_config, &app_state, &mut infos); } @@ -271,7 +277,7 @@ pub async fn start_server(cfg: Arc, targets: Arc) -> fut .merge(xmltv_api_register()) .merge(hls_api_register()); // let mut rate_limiting = false; - if let Some(rate_limiter) = app_state.config.reverse_proxy.as_ref().and_then(|r| r.rate_limit.clone()) { + if let Some(rate_limiter) = cfg.reverse_proxy.as_ref().and_then(|r| r.rate_limit.clone()) { // rate_limiting = rate_limiter.enabled; api_router = add_rate_limiter(api_router, &rate_limiter); } diff --git a/backend/src/api/model/active_provider_manager.rs b/backend/src/api/model/active_provider_manager.rs index f87dcf9cf..d9c1c1b9b 100644 --- a/backend/src/api/model/active_provider_manager.rs +++ b/backend/src/api/model/active_provider_manager.rs @@ -1,4 +1,4 @@ -use crate::model::{Config, ConfigInput}; +use crate::model::{AppConfig, ConfigInput}; use log::{debug, log_enabled}; use std::collections::HashMap; use std::ops::Deref; @@ -401,8 +401,8 @@ pub struct ActiveProviderManager { } impl ActiveProviderManager { - pub async fn new(cfg: &Config) -> Self { - let (grace_period_millis, grace_period_timeout_secs) = cfg.reverse_proxy.as_ref() + pub async fn new(cfg: &AppConfig) -> Self { + let (grace_period_millis, grace_period_timeout_secs) = cfg.config.load().reverse_proxy.as_ref() .and_then(|r| r.stream.as_ref()) .map_or_else(|| (default_grace_period_millis(), default_grace_period_timeout_secs()), |s| (s.grace_period_millis, s.grace_period_timeout_secs)); @@ -411,7 +411,7 @@ impl ActiveProviderManager { grace_period_timeout_secs, providers: Arc::new(RwLock::new(Vec::new())), }; - for source in &cfg.sources.sources { + for source in &cfg.sources.load().sources { for input in &source.inputs { this.add_provider(input).await; } @@ -575,9 +575,10 @@ impl ActiveProviderManager { mod tests { use std::sync::atomic::AtomicU16; use super::*; - use crate::model::{ConfigInputAlias, InputFetchMethod, InputType}; + use crate::model::{ConfigInputAlias}; use crate::Arc; use std::thread; + use shared::model::{InputFetchMethod, InputType}; macro_rules! should_available { ($lineup:expr, $provider_id:expr, $grace_period_timeout_secs: expr) => { @@ -629,7 +630,6 @@ mod tests { headers: HashMap::default(), options: None, method: InputFetchMethod::default(), - t_base_url: String::default(), } } @@ -643,7 +643,6 @@ mod tests { password: Some("alias_pass".to_string()), priority, max_connections, - t_base_url: String::default(), } } diff --git a/backend/src/api/model/active_user_manager.rs b/backend/src/api/model/active_user_manager.rs index 35c1ebf60..583be66c2 100644 --- a/backend/src/api/model/active_user_manager.rs +++ b/backend/src/api/model/active_user_manager.rs @@ -1,7 +1,6 @@ use crate::model::Config; use crate::model::{ProxyUserCredentials}; -use crate::utils::request::sanitize_sensitive_info; -use shared::utils::{current_time_secs, default_grace_period_millis, default_grace_period_timeout_secs}; +use shared::utils::{current_time_secs, default_grace_period_millis, default_grace_period_timeout_secs, sanitize_sensitive_info}; use jsonwebtoken::get_current_timestamp; use log::{debug, info}; use std::collections::HashMap; diff --git a/backend/src/api/model/app_state.rs b/backend/src/api/model/app_state.rs index 190274b3f..5cad952c1 100644 --- a/backend/src/api/model/app_state.rs +++ b/backend/src/api/model/app_state.rs @@ -5,12 +5,12 @@ use crate::api::model::active_provider_manager::ActiveProviderManager; use crate::api::model::active_user_manager::ActiveUserManager; use crate::api::model::download::DownloadQueue; use crate::api::model::streams::shared_stream_manager::SharedStreamManager; -use crate::model::{Config, HdHomeRunDeviceConfig}; +use crate::model::{AppConfig, HdHomeRunDeviceConfig}; use crate::tools::lru_cache::LRUResourceCache; #[derive(Clone)] pub struct AppState { - pub config: Arc, + pub config: Arc, pub http_client: Arc, pub downloads: Arc, pub cache: Arc>>, diff --git a/backend/src/api/model/config.rs b/backend/src/api/model/config.rs index 5fe0597cb..60b39c5ba 100644 --- a/backend/src/api/model/config.rs +++ b/backend/src/api/model/config.rs @@ -1,6 +1,5 @@ use serde::{Deserialize, Serialize}; -use shared::model::ProcessingOrder; -use crate::model::{ApiProxyConfig, ConfigApi, InputType, LogConfig, MessagingConfig, ReverseProxyConfig, ScheduleConfig, VideoConfig, ConfigSort, WebUiConfig, ProxyConfig, IpCheckConfig, ConfigTargetOptions, TargetOutput, ConfigRename}; +use shared::model::{ApiProxyConfigDto, ConfigApiDto, ConfigRenameDto, ConfigSortDto, ConfigTargetOptions, InputType, IpCheckConfigDto, LogConfigDto, MessagingConfigDto, ProcessingOrder, ProxyConfigDto, ReverseProxyConfigDto, ScheduleConfigDto, TargetOutputDto, VideoConfigDto, WebUiConfigDto}; #[derive(Deserialize, Serialize, Debug, Clone)] pub struct ServerInputConfig { @@ -20,11 +19,11 @@ pub struct ServerTargetConfig { pub enabled: bool, pub name: String, pub options: Option, - pub sort: Option, + pub sort: Option, pub filter: String, #[serde(alias = "type")] - pub output: Vec, - pub rename: Option>, + pub output: Vec, + pub rename: Option>, pub mapping: Option>, pub processing_order: ProcessingOrder, pub watch: Option>, @@ -38,21 +37,21 @@ pub struct ServerSourceConfig { #[derive(Deserialize, Serialize, Debug, Clone)] pub struct ServerConfig { - pub api: ConfigApi, + pub api: ConfigApiDto, pub threads: u8, pub working_dir: String, pub backup_dir: Option, pub user_config_dir: Option, - pub schedules: Option>, - pub reverse_proxy: Option, + pub schedules: Option>, + pub reverse_proxy: Option, pub sources: Vec, - pub messaging: Option, - pub video: Option, - pub api_proxy: Option, - pub log: Option, + pub messaging: Option, + pub video: Option, + pub api_proxy: Option, + pub log: Option, pub update_on_boot: bool, - pub web_ui: Option, - pub proxy: Option, - pub ipcheck: Option, + pub web_ui: Option, + pub proxy: Option, + pub ipcheck: Option, } diff --git a/backend/src/api/model/download.rs b/backend/src/api/model/download.rs index 36862f959..2b9489346 100644 --- a/backend/src/api/model/download.rs +++ b/backend/src/api/model/download.rs @@ -7,8 +7,7 @@ use deunicode::deunicode; use serde::{Deserialize, Serialize}; use crate::model::{VideoDownloadConfig}; -use crate::utils::hash_string_as_hex; -use shared::utils::{CONSTANTS, FILENAME_TRIM_PATTERNS}; +use shared::utils::{CONSTANTS, FILENAME_TRIM_PATTERNS, hash_string_as_hex}; /// File-Download information. #[derive(Clone)] @@ -43,7 +42,7 @@ pub struct FileDownload { fn get_download_directory(download_cfg: &VideoDownloadConfig, filestem: &str) -> PathBuf { if download_cfg.organize_into_directories { let mut stem = filestem; - if let Some(re) = &download_cfg.t_re_episode_pattern { + if let Some(re) = &download_cfg.episode_pattern { if let Some(captures) = re.captures(stem) { if let Some(episode) = captures.name("episode") { if !episode.as_str().is_empty() { diff --git a/backend/src/api/model/provider_config.rs b/backend/src/api/model/provider_config.rs index 8ad008b72..cb325e38f 100644 --- a/backend/src/api/model/provider_config.rs +++ b/backend/src/api/model/provider_config.rs @@ -1,10 +1,11 @@ use crate::api::model::active_provider_manager::ProviderAllocation; -use crate::model::{ConfigInput, ConfigInputAlias, InputType, InputUserInfo}; +use crate::model::{ConfigInput, ConfigInputAlias, InputUserInfo}; use jsonwebtoken::get_current_timestamp; use log::debug; use std::ops::Deref; use std::sync::Arc; use tokio::sync::RwLock; +use shared::model::InputType; #[derive(Debug, Clone, Copy)] pub enum ProviderConfigAllocation { diff --git a/backend/src/api/model/streams/active_client_stream.rs b/backend/src/api/model/streams/active_client_stream.rs index 87842e0cd..9cba0b09e 100644 --- a/backend/src/api/model/streams/active_client_stream.rs +++ b/backend/src/api/model/streams/active_client_stream.rs @@ -59,7 +59,7 @@ impl ActiveClientStream { )); let stream = stream_details.stream.take().unwrap(); - let stream = match app_state.config.sleep_timer_mins { + let stream = match app_state.config.config.load().sleep_timer_mins { None => stream, Some(mins) => { let secs = u32::try_from((u64::from(mins) * 60).min(u64::from(u32::MAX))).unwrap_or(0); diff --git a/backend/src/api/model/streams/client_stream.rs b/backend/src/api/model/streams/client_stream.rs index c6fc52dca..f0458d38f 100644 --- a/backend/src/api/model/streams/client_stream.rs +++ b/backend/src/api/model/streams/client_stream.rs @@ -5,11 +5,11 @@ use std::sync::Arc; use std::task::{Poll}; use futures::{Stream}; use log::trace; +use shared::utils::sanitize_sensitive_info; use crate::api::model::stream::BoxedProviderStream; use crate::api::model::stream_error::StreamError; use crate::utils::trace_if_enabled; use crate::tools::atomic_once_flag::AtomicOnceFlag; -use crate::utils::request::sanitize_sensitive_info; /// This stream counts the send bytes for reconnecting to the actual position and /// sets the `close_signal` if the client drops the connection. diff --git a/backend/src/api/model/streams/provider_stream.rs b/backend/src/api/model/streams/provider_stream.rs index 01dbb27ba..fc7d999e4 100644 --- a/backend/src/api/model/streams/provider_stream.rs +++ b/backend/src/api/model/streams/provider_stream.rs @@ -1,6 +1,6 @@ use crate::api::api_utils::{HeaderFilter}; use crate::api::model::streams::custom_video_stream::CustomVideoStream; -use crate::model::{Config}; +use crate::model::{AppConfig}; use shared::model::PlaylistItemType; use log::{trace}; use reqwest::StatusCode; @@ -29,27 +29,27 @@ fn create_video_stream(video_buffer: Option<&TransportStreamBuffer>, headers: &[ } } -pub fn create_channel_unavailable_stream(cfg: &Config, headers: &[(String, String)], status: StatusCode) -> ProviderStreamResponse { +pub fn create_channel_unavailable_stream(cfg: &AppConfig, headers: &[(String, String)], status: StatusCode) -> ProviderStreamResponse { let video = cfg.t_custom_stream_response.as_ref().and_then(|c| c.channel_unavailable.as_ref()); create_video_stream(video, headers, &format!("Streaming response channel unavailable for status {status}")) } -pub fn create_user_connections_exhausted_stream(cfg: &Config, headers: &[(String, String)]) -> ProviderStreamResponse { +pub fn create_user_connections_exhausted_stream(cfg: &AppConfig, headers: &[(String, String)]) -> ProviderStreamResponse { let video = cfg.t_custom_stream_response.as_ref().and_then(|c| c.user_connections_exhausted.as_ref()); create_video_stream(video, headers, "Streaming response user connections exhausted") } -pub fn create_provider_connections_exhausted_stream(cfg: &Config, headers: &[(String, String)]) -> ProviderStreamResponse { +pub fn create_provider_connections_exhausted_stream(cfg: &AppConfig, headers: &[(String, String)]) -> ProviderStreamResponse { let video = cfg.t_custom_stream_response.as_ref().and_then(|c| c.provider_connections_exhausted.as_ref()); create_video_stream(video, headers, "Streaming response provider connections exhausted") } -pub fn create_user_account_expired_stream(cfg: &Config, headers: &[(String, String)]) -> ProviderStreamResponse { +pub fn create_user_account_expired_stream(cfg: &AppConfig, headers: &[(String, String)]) -> ProviderStreamResponse { let video = cfg.t_custom_stream_response.as_ref().and_then(|c| c.user_account_expired.as_ref()); create_video_stream(video, headers, "Streaming response user account expired") } -pub fn create_custom_video_stream_response(config: &Config, video_response: CustomVideoStreamType) -> impl axum::response::IntoResponse + Send { +pub fn create_custom_video_stream_response(config: &AppConfig, video_response: CustomVideoStreamType) -> impl axum::response::IntoResponse + Send { if let (Some(stream), Some((headers, status_code, _))) = match video_response { CustomVideoStreamType::ChannelUnavailable => create_channel_unavailable_stream(config, &[], StatusCode::BAD_REQUEST), CustomVideoStreamType::UserConnectionsExhausted => create_user_connections_exhausted_stream(config, &[]), diff --git a/backend/src/api/model/streams/provider_stream_factory.rs b/backend/src/api/model/streams/provider_stream_factory.rs index 77498a45d..020ebfdf4 100644 --- a/backend/src/api/model/streams/provider_stream_factory.rs +++ b/backend/src/api/model/streams/provider_stream_factory.rs @@ -6,12 +6,12 @@ use crate::api::model::streams::buffered_stream::BufferedStream; use crate::api::model::streams::client_stream::ClientStream; use crate::api::model::streams::provider_stream::{create_channel_unavailable_stream, get_header_filter_for_item_type}; use crate::api::model::streams::timed_client_stream::TimedClientStream; -use shared::model::PlaylistItemType; -use crate::model::{Config, DEFAULT_USER_AGENT}; +use shared::model::{PlaylistItemType, DEFAULT_USER_AGENT}; +use crate::model::{AppConfig}; use crate::tools::atomic_once_flag::AtomicOnceFlag; -use crate::utils::request::{classify_content_type, get_request_headers, sanitize_sensitive_info, MimeCategory}; +use crate::utils::request::{classify_content_type, get_request_headers, MimeCategory}; use crate::utils::{debug_if_enabled}; -use shared::utils::{filter_request_header}; +use shared::utils::{filter_request_header, sanitize_sensitive_info}; use futures::stream::{self}; use futures::{StreamExt, TryStreamExt}; use log::{debug, log_enabled, warn}; @@ -236,7 +236,7 @@ fn prepare_client(request_client: &Arc, stream_options: &Provid (request_builder, partial) } -async fn provider_stream_request(cfg: &Config, request_client: Arc, stream_options: &ProviderStreamFactoryOptions) -> Result, StatusCode> { +async fn provider_stream_request(cfg: &AppConfig, request_client: Arc, stream_options: &ProviderStreamFactoryOptions) -> Result, StatusCode> { let (client, _partial_content) = prepare_client(&request_client, stream_options); match client.send().await { Ok(mut response) => { @@ -319,7 +319,7 @@ async fn provider_stream_request(cfg: &Config, request_client: Arc, stream_options: &ProviderStreamFactoryOptions) -> Result, StatusCode> { +async fn get_provider_stream(cfg: &AppConfig, client: Arc, stream_options: &ProviderStreamFactoryOptions) -> Result, StatusCode> { let url = stream_options.get_url(); debug_if_enabled!("stream provider {}", sanitize_sensitive_info(url.as_str())); let start = Instant::now(); @@ -367,7 +367,7 @@ async fn get_provider_stream(cfg: &Config, client: Arc, stream_ } -pub async fn create_provider_stream(cfg: Arc, +pub async fn create_provider_stream(cfg: Arc, client: Arc, stream_options: ProviderStreamFactoryOptions) -> Option { let client_stream_factory = |stream, reconnect_flag, range_cnt| { diff --git a/backend/src/api/model/streams/shared_stream_manager.rs b/backend/src/api/model/streams/shared_stream_manager.rs index c80ce66ab..f61a2c1e7 100644 --- a/backend/src/api/model/streams/shared_stream_manager.rs +++ b/backend/src/api/model/streams/shared_stream_manager.rs @@ -2,7 +2,6 @@ use crate::api::model::app_state::AppState; use crate::api::model::stream_error::StreamError; use crate::api::model::streams::provider_stream_factory::STREAM_QUEUE_SIZE; use crate::utils::debug_if_enabled; -use crate::utils::request::sanitize_sensitive_info; use bytes::Bytes; use futures::stream::{BoxStream, FuturesUnordered}; use futures::{Stream, StreamExt}; @@ -20,6 +19,7 @@ use std::task::{Context, Poll}; use tokio::sync::mpsc::error::TrySendError; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; +use shared::utils::sanitize_sensitive_info; /// /// Wraps a `ReceiverStream` as Stream> diff --git a/backend/src/api/scheduler.rs b/backend/src/api/scheduler.rs index 052c52cdf..c9f7b275a 100644 --- a/backend/src/api/scheduler.rs +++ b/backend/src/api/scheduler.rs @@ -5,7 +5,7 @@ use chrono::{DateTime, FixedOffset, Local}; use cron::Schedule; use crate::utils::{exit}; use log::{error}; -use crate::model::{Config, ProcessTargets}; +use crate::model::{AppConfig, ProcessTargets}; use crate::processing::processor::playlist::exec_processing; pub fn datetime_to_instant(datetime: DateTime) -> Instant { @@ -24,7 +24,7 @@ pub fn datetime_to_instant(datetime: DateTime) -> Instant { Instant::now() + duration_until } -pub async fn start_scheduler(client: Arc, expression: &str, config: Arc, targets: Arc) -> ! { +pub async fn start_scheduler(client: Arc, expression: &str, config: Arc, targets: Arc) -> ! { match Schedule::from_str(expression) { Ok(schedule) => { let offset = *Local::now().offset(); diff --git a/backend/src/auth/access_token.rs b/backend/src/auth/access_token.rs index af2cf3fef..e5bb99bff 100644 --- a/backend/src/auth/access_token.rs +++ b/backend/src/auth/access_token.rs @@ -1,4 +1,4 @@ -use crate::utils::{hex_decode, hex_encode}; +use shared::utils::{hex_decode, hex_encode}; use chrono::Utc; use serde::{Deserialize, Serialize}; diff --git a/backend/src/auth/authenticator.rs b/backend/src/auth/authenticator.rs index be3577c10..30d2874dd 100644 --- a/backend/src/auth/authenticator.rs +++ b/backend/src/auth/authenticator.rs @@ -82,7 +82,7 @@ fn validate_request( token: &str, verify_fn: fn(&str, &[u8]) -> bool, ) -> Result<(), ()> { - if let Some(web_auth_config) =&app_state.config.web_ui.as_ref().and_then(|c| c.auth.as_ref()) { + if let Some(web_auth_config) = &app_state.config.config.load().web_ui.as_ref().and_then(|c| c.auth.as_ref()) { let secret_key = web_auth_config.secret.as_ref(); if verify_fn(token, secret_key) { return Ok(()); diff --git a/backend/src/foundation/mod.rs b/backend/src/foundation/mod.rs deleted file mode 100644 index 9606fb213..000000000 --- a/backend/src/foundation/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub(crate) mod filter; -pub(crate) mod mapper; diff --git a/backend/src/main.rs b/backend/src/main.rs index e49f52327..38d7f4820 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -10,16 +10,17 @@ mod modules; include_modules!(); use crate::auth::generate_password; -use crate::model::{Config, Healthcheck, HealthcheckConfig, ProcessTargets}; +use crate::model::{AppConfig, Config, Healthcheck, HealthcheckConfig, ProcessTargets}; use crate::processing::processor::playlist; use crate::utils::{config_file_reader, resolve_env_var}; -use crate::utils::request::{create_client, set_sanitize_sensitive_info}; +use crate::utils::request::{create_client}; use chrono::{DateTime, Utc}; -use clap::Parser; +use clap::{Parser}; use log::{error, info}; use std::fs::File; use std::path::{Path, PathBuf}; use std::sync::Arc; +use shared::utils::set_sanitize_sensitive_info; use crate::utils::init_logger; #[derive(Parser)] @@ -108,56 +109,56 @@ fn main() { } let sources_file: String = args.source_file.unwrap_or_else(|| utils::get_default_sources_file_path(&config_path)); - let cfg = utils::read_config(config_path.as_str(), config_file.as_str(), + let app_config = utils::read_config(config_path.as_str(), config_file.as_str(), sources_file.as_str(), api_proxy_file.as_str(), mappings_file.cloned(), true).unwrap_or_else(|err| exit!("{}", err)); - set_sanitize_sensitive_info(cfg.log.as_ref().is_none_or(|l| l.sanitize_sensitive_info)); + let config = app_config.config.load(); + set_sanitize_sensitive_info(config.log.as_ref().is_none_or(|l| l.sanitize_sensitive_info)); - let temp_path = PathBuf::from(&cfg.working_dir).join("tmp"); - create_directories(&cfg, &temp_path); + let temp_path = PathBuf::from(&config.working_dir).join("tmp"); + create_directories(&config, &temp_path); let _ = tempfile::env::override_temp_dir(&temp_path); - let targets = cfg.sources.validate_targets(args.target.as_ref()).unwrap_or_else(|err| exit!("{}", err)); + let sources = app_config.sources.load(); + let targets = sources.validate_targets(args.target.as_ref()).unwrap_or_else(|err| exit!("{}", err)); info!("Current time: {}", chrono::offset::Local::now().format("%Y-%m-%d %H:%M:%S")); info!("Temp dir: {}", temp_path.display()); - info!("Working dir: {:?}", &cfg.working_dir); - info!("Config dir: {:?}", &cfg.t_config_path); - info!("Config file: {:?}", &cfg.t_config_file_path); - info!("Source file: {:?}", &cfg.t_sources_file_path); - info!("Api Proxy File: {:?}", &cfg.t_api_proxy_file_path); - match utils::read_mappings(&cfg.t_mapping_file_path, true) { + info!("Working dir: {:?}", &config.working_dir); + info!("Config dir: {:?}", &app_config.t_config_path); + info!("Config file: {:?}", &app_config.t_config_file_path); + info!("Source file: {:?}", &app_config.t_sources_file_path); + info!("Api Proxy File: {:?}", &app_config.t_api_proxy_file_path); + match utils::read_mappings(&app_config.t_mapping_file_path, true) { Ok(Some(mappings)) => { - info!("Mapping file: {:?}", &cfg.t_mapping_file_path); - cfg.set_mappings(&mappings); + info!("Mapping file: {:?}", &app_config.t_mapping_file_path); + app_config.set_mappings(&mappings); } Ok(None) => { info!("Mapping file: not used"); }, Err(err) => exit!("{err}"), } - if let Some(cache) = cfg.reverse_proxy.as_ref().and_then(|r| r.cache.as_ref()) { + if let Some(cache) = config.reverse_proxy.as_ref().and_then(|r| r.cache.as_ref()) { if cache.enabled { - if let Some(cache_dir) = cache.dir.as_ref() { - info!("Cache dir: {cache_dir}"); - } + info!("Cache dir: {}", cache.dir); } } - if let Some(resource_path) = cfg.t_custom_stream_response_path.as_ref() { + if let Some(resource_path) = app_config.t_custom_stream_response_path.as_ref() { info!("Resource path: {resource_path}"); } let rt = tokio::runtime::Runtime::new().unwrap(); let () = rt.block_on(async { if args.server { - match utils::read_api_proxy_config(&cfg) { + match utils::read_api_proxy_config(&app_config) { Ok(()) => {} Err(err) => exit!("{err}"), } - start_in_server_mode(Arc::new(cfg), Arc::new(targets)).await; + start_in_server_mode(Arc::new(app_config), Arc::new(targets)).await; } else { - start_in_cli_mode(Arc::new(cfg), Arc::new(targets)).await; + start_in_cli_mode(Arc::new(app_config), Arc::new(targets)).await; } }); } @@ -169,7 +170,7 @@ fn create_directories(cfg: &Config, temp_path: &Path) { cfg.backup_dir.clone(), cfg.user_config_dir.clone(), cfg.video.as_ref().and_then(|v| v.download.as_ref()).and_then(|d| d.directory.clone()), - cfg.reverse_proxy.as_ref().and_then(|r| r.cache.as_ref().and_then(|c| if c.enabled { c.dir.clone() } else { None })) + cfg.reverse_proxy.as_ref().and_then(|r| r.cache.as_ref().and_then(|c| if c.enabled { Some(c.dir.to_string()) } else { None })) ]; let mut paths: Vec = paths_strings.iter() @@ -191,7 +192,7 @@ fn create_directories(cfg: &Config, temp_path: &Path) { } } -async fn start_in_cli_mode(cfg: Arc, targets: Arc) { +async fn start_in_cli_mode(cfg: Arc, targets: Arc) { let client = create_client(&cfg).build().unwrap_or_else(|err| { error!("Failed to build client {err}"); reqwest::Client::new() @@ -199,7 +200,7 @@ async fn start_in_cli_mode(cfg: Arc, targets: Arc) { playlist::exec_processing(Arc::new(client), cfg, targets).await; } -async fn start_in_server_mode(cfg: Arc, targets: Arc) { +async fn start_in_server_mode(cfg: Arc, targets: Arc) { if let Err(err) = api::main_api::start_server(cfg, targets).await { exit!("Can't start server: {err}"); } diff --git a/backend/src/messaging.rs b/backend/src/messaging.rs index 6a8e5ddb1..a2a961f36 100644 --- a/backend/src/messaging.rs +++ b/backend/src/messaging.rs @@ -43,16 +43,16 @@ fn send_telegram_message(msg: &str, messaging: &MessagingConfig) { fn send_pushover_message(client: &Arc, msg: &str, messaging: &MessagingConfig) { if let Some(pushover) = &messaging.pushover { - let url = pushover.url.as_deref().unwrap_or("https://api.pushover.net/1/messages.json").to_string(); let encoded_message: String = url::form_urlencoded::Serializer::new(String::new()) .append_pair("token", pushover.token.as_str()) .append_pair("user", pushover.user.as_str()) .append_pair("message", msg) .finish(); let the_client = Arc::clone(client); + let pushover_url = pushover.url.to_string(); tokio::spawn(async move { match the_client - .post(url) + .post(pushover_url) .header(header::CONTENT_TYPE, mime::APPLICATION_WWW_FORM_URLENCODED.to_string()) .body(encoded_message) .send() diff --git a/backend/src/model/config/api.rs b/backend/src/model/config/api.rs new file mode 100644 index 000000000..d41f555b0 --- /dev/null +++ b/backend/src/model/config/api.rs @@ -0,0 +1,30 @@ +use shared::model::ConfigApiDto; +use crate::model::macros; + +#[derive(Debug, Clone, Default)] +pub struct ConfigApi { + pub host: String, + pub port: u16, + pub web_root: String, +} + +macros::from_impl!(ConfigApi); +impl From<&ConfigApiDto> for ConfigApi { + fn from(dto: &ConfigApiDto) -> Self { + Self { + host:dto.host.to_string(), + port: dto.port, + web_root: dto.web_root.to_string(), + } + } +} + +impl From<&ConfigApi> for ConfigApiDto { + fn from(instance: &ConfigApi) -> Self { + Self { + host: instance.host.to_string(), + port: instance.port, + web_root: instance.web_root.to_string(), + } + } +} \ No newline at end of file diff --git a/backend/src/model/config/api_proxy.rs b/backend/src/model/config/api_proxy.rs index e76b305a6..510e643c0 100644 --- a/backend/src/model/config/api_proxy.rs +++ b/backend/src/model/config/api_proxy.rs @@ -1,208 +1,56 @@ -use crate::api::model::app_state::AppState; -use shared::error::{info_err, TuliproxError, TuliproxErrorKind}; -use crate::model::{Config}; +use crate::model::{macros, AppConfig, Config, ProxyUserCredentials, TargetUser}; use crate::repository::user_repository::{backup_api_user_db_file, get_api_user_db_path, load_api_user, merge_api_user}; use crate::utils::{save_api_proxy}; -use shared::utils::{default_as_true}; -use chrono::Local; use log::debug; use std::cmp::PartialEq; -use std::collections::HashSet; use std::fs; -use shared::model::{ProxyType, ProxyUserStatus, UserConnectionPermission}; -use crate::utils; +use std::sync::Arc; +use arc_swap::access::Access; +use arc_swap::ArcSwap; +use shared::model::{ApiProxyConfigDto, ApiProxyServerInfoDto, TargetUserDto}; +use crate::{utils}; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProxyUserCredentials { - pub username: String, - pub password: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub token: Option, - #[serde(default = "ProxyType::default")] - pub proxy: ProxyType, - #[serde(skip_serializing_if = "Option::is_none")] - pub server: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub epg_timeshift: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub created_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub exp_date: Option, - #[serde(default)] - pub max_connections: u32, - #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, - #[serde(default = "default_as_true")] - pub ui_enabled: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub comment: Option, -} - -impl ProxyUserCredentials { - pub fn prepare(&mut self) { - self.trim(); - } - - pub fn matches_token(&self, token: &str) -> bool { - if let Some(tkn) = &self.token { - return tkn.eq(token); - } - false - } - - pub fn matches(&self, username: &str, password: &str) -> bool { - self.username.eq(username) && self.password.eq(password) - } - - pub fn trim(&mut self) { - self.username = self.username.trim().to_string(); - self.password = self.password.trim().to_string(); - match &self.token { - None => {} - Some(tkn) => { - self.token = Some(tkn.trim().to_string()); - } - } - } - - pub fn validate(&self) -> Result<(), TuliproxError> { - if self.username.is_empty() { - return Err(TuliproxError::new(TuliproxErrorKind::Info, "Username required".to_string())); - } - if self.password.is_empty() { - return Err(TuliproxError::new(TuliproxErrorKind::Info, "Password required".to_string())); - } - Ok(()) - } - - pub fn has_permissions(&self, app_state: &AppState) -> bool { - if app_state.config.user_access_control { - if let Some(exp_date) = self.exp_date.as_ref() { - let now = Local::now(); - if (exp_date - now.timestamp()) < 0 { - debug!("User access denied, expired: {}", self.username); - return false; - } - } - - if let Some(status) = &self.status { - if !matches!(status, ProxyUserStatus::Active | ProxyUserStatus::Trial) { - debug!("User access denied, status invalid: {status} for user: {}", self.username); - return false; - } - } // NO STATUS SET, ok admins fault, we take this as a valid status - } - true - } - - #[inline] - pub fn permission_denied(&self, app_state: &AppState) -> bool { - !self.has_permissions(app_state) - } - - pub async fn connection_permission(&self, app_state: &AppState) -> UserConnectionPermission { - if self.max_connections > 0 && app_state.config.user_access_control { - // we allow requests with max connection reached, but we should block streaming after grace period - return app_state.get_connection_permission(&self.username, self.max_connections).await; - } - UserConnectionPermission::Allowed - } -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct TargetUser { - pub target: String, - pub credentials: Vec, -} - -impl TargetUser { - pub fn get_target_name( - &self, - username: &str, - password: &str, - ) -> Option<(&ProxyUserCredentials, &str)> { - self.credentials - .iter() - .find(|c| c.matches(username, password)) - .map(|credentials| (credentials, self.target.as_str())) - } - pub fn get_target_name_by_token(&self, token: &str) -> Option<(&ProxyUserCredentials, &str)> { - self.credentials - .iter() - .find(|c| c.matches_token(token)) - .map(|credentials| (credentials, self.target.as_str())) - } -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct ApiProxyServerInfo { pub name: String, pub protocol: String, pub host: String, - #[serde(default, skip_serializing_if = "Option::is_none")] pub port: Option, pub timezone: String, pub message: String, - #[serde(default, skip_serializing_if = "Option::is_none")] pub path: Option, } -impl ApiProxyServerInfo { - - pub fn prepare(&mut self ) -> Result<(), TuliproxError> { - self.name = self.name.trim().to_string(); - if self.name.is_empty() { - return Err(info_err!("Server info name is empty ".to_string())); - } - self.protocol = self.protocol.trim().to_string(); - if self.protocol.is_empty() { - return Err(info_err!("protocol cant be empty for api server config".to_string())); - } - self.host = self.host.trim().to_string(); - if self.host.is_empty() { - return Err(info_err!("host cant be empty for api server config".to_string())); - } - if let Some(port)= self.port.as_ref() { - let port = port.trim().to_string(); - if port.is_empty() { - self.port = None; - } else if port.parse::().is_err() { - return Err(info_err!("invalid port for api server config".to_string())); - } else { - self.port = Some(port); - } - } - - self.timezone = self.timezone.trim().to_string(); - if self.timezone.is_empty() { - self.timezone = "UTC".to_string(); - } - if self.message.is_empty() { - self.message = "Welcome to tuliprox".to_string(); - } - if let Some(path) = &self.path { - if path.trim().is_empty() { - self.path = None; - } - } - - if let Some(path) = &self.path { - let trimmed_path = path.trim(); - if trimmed_path.is_empty() { - self.path = None; - } else { - self.path = Some(trimmed_path.to_string()); - } - } - - Ok(()) - } - pub fn validate(&mut self) -> bool { - self.prepare().is_ok() +macros::from_impl!(ApiProxyServerInfo); +impl From<&ApiProxyServerInfoDto> for ApiProxyServerInfo { + fn from(dto: &ApiProxyServerInfoDto) -> Self { + Self { + name: dto.name.to_string(), + protocol: dto.protocol.to_string(), + host: dto.host.to_string(), + port: dto.port.clone(), + timezone: dto.timezone.to_string(), + message: dto.message.to_string(), + path: dto.path.clone(), + } } +} +impl From<&ApiProxyServerInfo> for ApiProxyServerInfoDto { + fn from(instance: &ApiProxyServerInfo) -> Self { + Self { + name: instance.name.to_string(), + protocol: instance.protocol.to_string(), + host: instance.host.to_string(), + port: instance.port.clone(), + timezone: instance.timezone.to_string(), + message: instance.message.to_string(), + path: instance.path.clone(), + } + } +} + +impl ApiProxyServerInfo { pub fn get_base_url(&self) -> String { let base_url = if let Some(port) = self.port.as_ref() { format!("{}://{}:{port}", self.protocol, self.host) @@ -217,20 +65,39 @@ impl ApiProxyServerInfo { } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct ApiProxyConfig { pub server: Vec, pub user: Vec, - #[serde(default)] pub use_user_db: bool, } +macros::from_impl!(ApiProxyConfig); +impl From<&ApiProxyConfigDto> for ApiProxyConfig { + fn from(dto: &ApiProxyConfigDto) -> Self { + Self { + server: dto.server.iter().map(ApiProxyServerInfo::from).collect(), + user: dto.user.iter().map(TargetUser::from).collect(), + use_user_db: dto.use_user_db, + } + } +} + +impl From<&ApiProxyConfig> for ApiProxyConfigDto { + fn from(instance: &ApiProxyConfig) -> Self { + Self { + server: instance.server.iter().map(ApiProxyServerInfoDto::from).collect(), + user: instance.user.iter().map(TargetUserDto::from).collect(), + use_user_db: instance.use_user_db, + } + } +} + impl ApiProxyConfig { // we have the option to store user in the config file or in the user_db // When we switch from one to other we need to migrate the existing data. /// # Panics - pub fn migrate_api_user(&mut self, cfg: &Config, errors: &mut Vec) { + pub fn migrate_api_user(&mut self, cfg: &AppConfig, errors: &mut Vec) { if self.use_user_db { // we have user defined in config file. // we migrate them to the db and delete them from the config file @@ -239,9 +106,10 @@ impl ApiProxyConfig { errors.push(err.to_string()); } else { let api_proxy_file = cfg.t_api_proxy_file_path.as_str(); - let backup_dir = cfg.backup_dir.as_ref().unwrap().as_str(); + let config = > as Access>::load(&cfg.config); + let backup_dir = config.backup_dir.as_ref().unwrap().as_str(); self.user = vec![]; - if let Err(err) = utils::save_api_proxy(api_proxy_file, backup_dir, self) { + if let Err(err) = utils::save_api_proxy(api_proxy_file, backup_dir, &ApiProxyConfigDto::from(&*self)) { errors.push(format!("Error saving api proxy file: {err}")); } } @@ -274,8 +142,10 @@ impl ApiProxyConfig { } } let api_proxy_file = cfg.t_api_proxy_file_path.as_str(); - let backup_dir = cfg.backup_dir.as_ref().unwrap().as_str(); - if let Err(err) = save_api_proxy(api_proxy_file, backup_dir, self) { + + let config = > as Access>::load(&cfg.config); + let backup_dir = config.backup_dir.as_ref().unwrap().as_str(); + if let Err(err) = save_api_proxy(api_proxy_file, backup_dir, &ApiProxyConfigDto::from(&*self)) { errors.push(format!("Error saving api proxy file: {err}")); } else { backup_api_user_db_file(cfg, &user_db_path); @@ -285,70 +155,6 @@ impl ApiProxyConfig { } } - fn prepare_server_config(&mut self, errors: &mut Vec) { - let mut name_set = HashSet::new(); - for server in &mut self.server { - if let Err(err) = server.prepare() { - errors.push(err.to_string()); - } - if name_set.contains(server.name.as_str()) { - errors.push(format!("Non-unique server info name found {}", &server.name)); - } else { - name_set.insert(server.name.clone()); - } - } - } - - fn prepare_target_user(&mut self, errors: &mut Vec) { - let mut usernames = HashSet::new(); - let mut tokens = HashSet::new(); - for target_user in &mut self.user { - for user in &mut target_user.credentials { - user.prepare(); - if usernames.contains(&user.username) { - errors.push(format!("Non unique username found {}", &user.username)); - } else { - usernames.insert(user.username.to_string()); - } - if let Some(token) = &user.token { - if token.is_empty() { - user.token = None; - } else if tokens.contains(token) { - errors.push(format!("Non unique token found {}", &user.username)); - } else { - tokens.insert(token.to_string()); - } - } - - if let Some(server_info_name) = &user.server { - if !&self.server.iter() - .any(|server_info| server_info.name.eq(server_info_name)) - { - errors.push(format!( - "No server info with name {} found for user {}", - server_info_name, &user.username - )); - } - } - } - } - } - - pub fn prepare(&mut self) -> Result<(), TuliproxError> { - let mut errors = Vec::new(); - if self.server.is_empty() { - errors.push("No server info defined".to_string()); - } else { - self.prepare_server_config(&mut errors); - } - self.prepare_target_user(&mut errors); - if errors.is_empty() { - Ok(()) - } else { - Err(info_err!(errors.join("\n"))) - } - } - pub fn get_target_name( &self, username: &str, diff --git a/backend/src/model/config/api_user.rs b/backend/src/model/config/api_user.rs new file mode 100644 index 000000000..e41c79b44 --- /dev/null +++ b/backend/src/model/config/api_user.rs @@ -0,0 +1,157 @@ +use std::sync::Arc; +use arc_swap::access::Access; +use arc_swap::ArcSwap; +use chrono::Local; +use log::debug; +use shared::model::{ProxyType, ProxyUserCredentialsDto, ProxyUserStatus, TargetUserDto, UserConnectionPermission}; +use crate::api::model::app_state::AppState; +use crate::model::{macros, Config}; + +#[derive(Debug, Clone)] +pub struct ProxyUserCredentials { + pub username: String, + pub password: String, + pub token: Option, + pub proxy: ProxyType, + pub server: Option, + pub epg_timeshift: Option, + pub created_at: Option, + pub exp_date: Option, + pub max_connections: u32, + pub status: Option, + pub ui_enabled: bool, + pub comment: Option, +} + +macros::from_impl!(ProxyUserCredentials); +impl From<&ProxyUserCredentialsDto> for ProxyUserCredentials { + fn from(dto: &ProxyUserCredentialsDto) -> Self { + Self { + username: dto.username.to_string(), + password: dto.password.to_string(), + token: dto.token.clone(), + proxy: dto.proxy, + server: dto.server.clone(), + epg_timeshift: dto.epg_timeshift.clone(), + created_at: dto.created_at, + exp_date: dto.exp_date, + max_connections: dto.max_connections, + status: dto.status, + ui_enabled: dto.ui_enabled, + comment: dto.comment.clone(), + } + } +} + +impl From<&ProxyUserCredentials> for ProxyUserCredentialsDto { + fn from(instance: &ProxyUserCredentials) -> Self { + Self { + username: instance.username.to_string(), + password: instance.password.to_string(), + token: instance.token.clone(), + proxy: instance.proxy, + server: instance.server.clone(), + epg_timeshift: instance.epg_timeshift.clone(), + created_at: instance.created_at, + exp_date: instance.exp_date, + max_connections: instance.max_connections, + status: instance.status, + ui_enabled: instance.ui_enabled, + comment: instance.comment.clone(), + } + } +} + + +impl ProxyUserCredentials { + + pub fn matches_token(&self, token: &str) -> bool { + if let Some(tkn) = &self.token { + return tkn.eq(token); + } + false + } + + pub fn matches(&self, username: &str, password: &str) -> bool { + self.username.eq(username) && self.password.eq(password) + } + + pub fn has_permissions(&self, app_state: &AppState) -> bool { + let config = > as Access>::load(&app_state.config.config); + if config.user_access_control { + if let Some(exp_date) = self.exp_date.as_ref() { + let now = Local::now(); + if (exp_date - now.timestamp()) < 0 { + debug!("User access denied, expired: {}", self.username); + return false; + } + } + + if let Some(status) = &self.status { + if !matches!(status, ProxyUserStatus::Active | ProxyUserStatus::Trial) { + debug!("User access denied, status invalid: {status} for user: {}", self.username); + return false; + } + } // NO STATUS SET, ok admins fault, we take this as a valid status + } + true + } + + #[inline] + pub fn permission_denied(&self, app_state: &AppState) -> bool { + !self.has_permissions(app_state) + } + + pub async fn connection_permission(&self, app_state: &AppState) -> UserConnectionPermission { + let config = > as Access>::load(&app_state.config.config); + if self.max_connections > 0 && config.user_access_control { + // we allow requests with max connection reached, but we should block streaming after grace period + return app_state.get_connection_permission(&self.username, self.max_connections).await; + } + UserConnectionPermission::Allowed + } +} + +#[derive(Debug, Clone)] +pub struct TargetUser { + pub target: String, + pub credentials: Vec, +} + +macros::from_impl!(TargetUser); +impl From<&TargetUserDto> for TargetUser { + fn from(dto: &TargetUserDto) -> Self { + Self { + target: dto.target.to_string(), + credentials: dto.credentials.iter().map(Into::into).collect(), + } + } +} + +impl From<&TargetUser> for TargetUserDto { + fn from(instance: &TargetUser) -> Self { + Self { + target: instance.target.to_string(), + credentials: instance.credentials.iter().map(Into::into).collect(), + } + } +} + +impl TargetUser { + pub fn get_target_name( + &self, + username: &str, + password: &str, + ) -> Option<(&ProxyUserCredentials, &str)> { + self.credentials + .iter() + .find(|c| c.matches(username, password)) + .map(|credentials| (credentials, self.target.as_str())) + } + pub fn get_target_name_by_token(&self, token: &str) -> Option<(&ProxyUserCredentials, &str)> { + self.credentials + .iter() + .find(|c| c.matches_token(token)) + .map(|credentials| (credentials, self.target.as_str())) + } +} \ No newline at end of file diff --git a/backend/src/model/config/app.rs b/backend/src/model/config/app.rs new file mode 100644 index 000000000..0474df228 --- /dev/null +++ b/backend/src/model/config/app.rs @@ -0,0 +1,363 @@ +use std::borrow::Cow; +use std::collections::HashSet; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use arc_swap::{ArcSwap, ArcSwapOption}; +use arc_swap::access::Access; +use log::{debug, error}; +use rand::Rng; +use shared::create_tuliprox_error_result; +use shared::error::{TuliproxError, TuliproxErrorKind}; +use crate::api::model::streams::transport_stream_buffer::TransportStreamBuffer; +use crate::model::{ApiProxyConfig, ApiProxyServerInfo, Config, ConfigInput, ConfigInputOptions, ConfigTarget, CustomStreamResponse, HdHomeRunConfig, Mappings, ProxyUserCredentials, SourcesConfig, TargetOutput}; +use crate::utils; + + +const CHANNEL_UNAVAILABLE: &str = "channel_unavailable.ts"; +const USER_CONNECTIONS_EXHAUSTED: &str = "user_connections_exhausted.ts"; +const PROVIDER_CONNECTIONS_EXHAUSTED: &str = "provider_connections_exhausted.ts"; +const USER_ACCOUNT_EXPIRED: &str = "user_account_expired.ts"; + +fn generate_secret() -> [u8; 32] { + let mut rng = rand::rng(); + let mut secret = [0u8; 32]; + rng.fill(&mut secret); + secret +} + +#[derive(Debug, Clone)] +pub struct AppConfig { + pub config: Arc>, + pub sources: Arc>, + pub t_hdhomerun: Arc>, + pub t_api_proxy: Arc>, + pub t_config_path: String, + pub t_config_file_path: String, + pub t_sources_file_path: String, + pub t_mapping_file_path: String, + pub t_api_proxy_file_path: String, + pub t_custom_stream_response_path: Option, + pub file_locks: Arc, + pub t_custom_stream_response: Option, + pub t_access_token_secret: [u8; 32], + pub t_encrypt_secret: [u8; 16], +} + +impl AppConfig { + pub fn set_api_proxy(&self, api_proxy: Option>) -> Result<(), TuliproxError> { + self.t_api_proxy.store(api_proxy); + self.check_target_user() + } + + fn check_username(&self, output_username: Option<&str>, target_name: &str) -> Result<(), TuliproxError> { + if let Some(username) = output_username { + if let Some((_, config_target)) = self.get_target_for_username(username) { + if config_target.name != target_name { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "User:{username} does not belong to target: {}", target_name); + } + } else { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "User: {username} does not exist"); + } + Ok(()) + } else { + Ok(()) + } + } + fn check_target_user(&self) -> Result<(), TuliproxError> { + let check_homerun = self.t_hdhomerun.load().as_ref().is_some_and(|h| h.enabled); + let sources = > as Access>::load(&self.sources); + for source in &sources.sources { + for target in &source.targets { + for output in &target.output { + match output { + TargetOutput::Xtream(_) | TargetOutput::M3u(_) => {} + TargetOutput::Strm(strm_output) => { + self.check_username(strm_output.username.as_deref(), &target.name)?; + } + TargetOutput::HdHomeRun(hdhomerun_output) => { + if check_homerun { + let hdhr_name = &hdhomerun_output.device; + self.check_username(Some(&hdhomerun_output.username), &target.name)?; + if let Some(old_hdhomerun) = self.t_hdhomerun.load().clone() { + let mut hdhomerun = (*old_hdhomerun).clone(); + for device in &mut hdhomerun.devices { + if &device.name == hdhr_name { + device.t_username.clone_from(&hdhomerun_output.username); + device.t_enabled = true; + } + } + self.t_hdhomerun.store(Some(Arc::new(hdhomerun))); + } + } + } + } + } + } + } + + let guard = self.t_hdhomerun.load(); + if let Some(hdhomerun) = &*guard { + for device in &hdhomerun.devices { + if !device.t_enabled { + debug!("HdHomeRun device '{}' has no username and will be disabled", device.name); + } + } + } + Ok(()) + } + + pub fn is_reverse_proxy_resource_rewrite_enabled(&self) -> bool { + let config = > as Access>::load(&self.config); + config.reverse_proxy.as_ref().is_none_or(|r| !r.resource_rewrite_disabled) + } + + fn intern_get_target_for_user(&self, user_target: Option<(ProxyUserCredentials, String)>) -> Option<(ProxyUserCredentials, Arc)> { + match user_target { + Some((user, target_name)) => { + let sources = > as Access>::load(&self.sources); + for source in &sources.sources { + for target in &source.targets { + if target_name.eq_ignore_ascii_case(&target.name) { + return Some((user, Arc::clone(target))); + } + } + } + None + } + 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 { + if let Some(cfg) = source.get_inputs_for_target(target_name) { + return Some(cfg); + } + } + None + } + + pub fn get_target_for_username(&self, username: &str) -> Option<(ProxyUserCredentials, Arc)> { + if let Some(credentials) = self.get_user_credentials(username) { + return self.t_api_proxy.load().as_ref() + .and_then(|api_proxy| self.intern_get_target_for_user(api_proxy.get_target_name(&credentials.username, &credentials.password))); + } + None + } + + pub fn get_target_for_user(&self, username: &str, password: &str) -> Option<(ProxyUserCredentials, Arc)> { + self.t_api_proxy.load().as_ref().and_then(|api_proxy| self.intern_get_target_for_user(api_proxy.get_target_name(username, password))) + } + + pub fn get_target_for_user_by_token(&self, token: &str) -> Option<(ProxyUserCredentials, Arc)> { + self.t_api_proxy.load().as_ref().as_ref().and_then(|api_proxy| self.intern_get_target_for_user(api_proxy.get_target_name_by_token(token))) + } + + pub fn get_user_credentials(&self, username: &str) -> Option { + self.t_api_proxy.load().as_ref().as_ref().and_then(|api_proxy| api_proxy.get_user_credentials(username)) + } + + pub fn get_input_by_name(&self, input_name: &str) -> Option> { + let sources = > as Access>::load(&self.sources); + for source in &sources.sources { + for input in &source.inputs { + if input.name == input_name { + return Some(Arc::clone(input)); + } + } + } + None + } + + pub fn get_input_options_by_name(&self, input_name: &str) -> Option { + let sources = > as Access>::load(&self.sources); + for source in &sources.sources { + for input in &source.inputs { + if input.name == input_name { + return input.options.clone(); + } + } + } + None + } + + pub fn get_input_by_id(&self, input_id: u16) -> Option> { + let sources = > as Access>::load(&self.sources); + for source in &sources.sources { + for input in &source.inputs { + if input.id == input_id { + return Some(Arc::clone(input)); + } + } + } + None + } + + pub fn get_target_by_id(&self, target_id: u16) -> Option> { + let sources = > as Access>::load(&self.sources); + sources.get_target_by_id(target_id) + } + + pub fn set_mappings(&self, mappings_cfg: &Mappings) { + let sources = > as Access>::load(&self.sources); + for source in &sources.sources { + for target in &source.targets { + if let Some(mapping_ids) = &target.mapping_ids { + let mut target_mappings = Vec::with_capacity(128); + for mapping_id in mapping_ids { + let mapping = mappings_cfg.get_mapping(mapping_id); + if let Some(mappings) = mapping { + target_mappings.push(mappings); + } + } + target.mapping.store(if target_mappings.is_empty() { None } else { Some(Arc::new(target_mappings)) }); + } + } + } + } + + fn check_unique_input_names(&self) -> Result<(), TuliproxError> { + let mut seen_names = HashSet::new(); + let sources = > as Access>::load(&self.sources); + for source in &sources.sources { + for input in &source.inputs { + let input_name = input.name.trim().to_string(); + if input_name.is_empty() { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "input name required"); + } + if seen_names.contains(input_name.as_str()) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "input names should be unique: {}", input_name); + } + seen_names.insert(input_name); + if let Some(aliases) = &input.aliases { + for alias in aliases { + let input_name = alias.name.trim().to_string(); + if input_name.is_empty() { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "input name required"); + } + if seen_names.contains(input_name.as_str()) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "input names should be unique: {}", input_name); + } + seen_names.insert(input_name); + } + } + } + } + Ok(()) + } + + + fn check_scheduled_targets(&mut self, target_names: &HashSet>) -> Result<(), TuliproxError> { + let config = > as Access>::load(&self.config); + if let Some(schedules) = &config.schedules { + for schedule in schedules { + if let Some(targets) = &schedule.targets { + for target_name in targets { + if !target_names.contains(target_name.as_str()) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Unknown target name in scheduler: {}", target_name); + } + } + } + } + } + Ok(()) + } + + /** + * if `include_computed` set to true for `app_state` + */ + pub fn prepare(&mut self, include_computed: bool) -> Result<(), TuliproxError> { + + if include_computed { + self.t_access_token_secret = generate_secret(); + self.t_encrypt_secret = <&[u8] as TryInto<[u8; 16]>>::try_into(&generate_secret()[0..16]).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, err.to_string()))?; + self.prepare_custom_stream_response(); + } + + let sources = > as Access>::load(&self.sources); + let target_names = sources.get_unique_target_names(); + self.check_scheduled_targets(&target_names)?; + self.check_unique_input_names()?; + + Ok(()) + } + + + fn prepare_custom_stream_response(&mut self) { + let config = > as Access>::load(&self.config); + if let Some(custom_stream_response_path) = config.custom_stream_response_path.as_ref() { + fn load_and_set_file(file_path: &Path) -> Option { + if file_path.exists() { + // Enforce maximum file size (10 MB) + if let Ok(meta) = std::fs::metadata(file_path) { + const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024; + if meta.len() > MAX_RESPONSE_SIZE { + error!("Custom stream response file too large ({} bytes): {}", + meta.len(), file_path.display()); + return None; + } + } + // Quick MPEG-TS sync-byte check (0x47) + if let Ok(mut f) = File::open(file_path) { + let mut buf = [0u8; 1]; + if f.read_exact(&mut buf).is_err() || buf[0] != 0x47 { + error!("Invalid MPEG-TS file: {}", file_path.display()); + return None; + } + } + + match utils::read_file_as_bytes(&PathBuf::from(&file_path)) { + Ok(data) => Some(TransportStreamBuffer::new(data, )), + Err(err) => { + error!("Failed to load a resource file: {} {err}", file_path.display()); + None + } + } + } else { + None + } + } + + let path = PathBuf::from(custom_stream_response_path); + let path = utils::make_path_absolute(&path, &config.working_dir); + self.t_custom_stream_response_path = Some(path.to_string_lossy().to_string()); + let channel_unavailable = load_and_set_file(&path.join(CHANNEL_UNAVAILABLE)); + let user_connections_exhausted = load_and_set_file(&path.join(USER_CONNECTIONS_EXHAUSTED)); + let provider_connections_exhausted = load_and_set_file(&path.join(PROVIDER_CONNECTIONS_EXHAUSTED)); + let user_account_expired = load_and_set_file(&path.join(USER_ACCOUNT_EXPIRED)); + self.t_custom_stream_response = Some(CustomStreamResponse { + channel_unavailable, + user_connections_exhausted, + provider_connections_exhausted, + user_account_expired, + }); + } + } + + + /// # Panics + /// + /// Will panic if default server invalid + pub fn get_server_info(&self, server_info_name: &str) -> ApiProxyServerInfo { + let guard = self.t_api_proxy.load(); + if let Ok(api_proxy) = guard.as_ref().ok_or_else(|| { + TuliproxError::new(TuliproxErrorKind::Info, "API proxy config not loaded".to_string()) + }) { + let server_info_list = api_proxy.server.clone(); + server_info_list.iter().find(|c| c.name.eq(server_info_name)).map_or_else(|| server_info_list.first().unwrap().clone(), Clone::clone) + } else { + panic!("ApiProxyServer info not found"); + } + } + + pub fn get_user_server_info(&self, user: &ProxyUserCredentials) -> ApiProxyServerInfo { + let server_info_name = user.server.as_ref().map_or("default", |server_name| server_name.as_str()); + self.get_server_info(server_info_name) + } + +} + + diff --git a/backend/src/model/config/base.rs b/backend/src/model/config/base.rs index e89ee66be..756031bc3 100644 --- a/backend/src/model/config/base.rs +++ b/backend/src/model/config/base.rs @@ -1,392 +1,51 @@ -use arc_swap::{ArcSwapOption}; -use std::collections::{HashSet}; -use std::fs::File; -use std::io::Read; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use log::{debug, error}; +use std::path::PathBuf; use path_clean::PathClean; -use rand::Rng; - -use crate::model::{ApiProxyConfig, ApiProxyServerInfo, CustomStreamResponse, Mappings, ProxyUserCredentials, ReverseProxyConfig, ScheduleConfig, SourcesConfig}; -use crate::model::{ConfigInput, ConfigInputOptions, ConfigTarget, HdHomeRunConfig, IpCheckConfig, LogConfig, MessagingConfig, ProxyConfig, TargetOutput, VideoConfig, WebUiConfig}; -use shared::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind}; -use shared::utils::{default_connect_timeout_secs}; - -const CHANNEL_UNAVAILABLE: &str = "channel_unavailable.ts"; -const USER_CONNECTIONS_EXHAUSTED: &str = "user_connections_exhausted.ts"; -const PROVIDER_CONNECTIONS_EXHAUSTED: &str = "provider_connections_exhausted.ts"; -const USER_ACCOUNT_EXPIRED: &str = "user_account_expired.ts"; - -fn generate_secret() -> [u8; 32] { - let mut rng = rand::rng(); - let mut secret = [0u8; 32]; - rng.fill(&mut secret); - secret -} - -#[macro_export] -macro_rules! valid_property { - ($key:expr, $array:expr) => {{ - $array.contains(&$key) - }}; -} -pub use valid_property; -use crate::api::model::streams::transport_stream_buffer::TransportStreamBuffer; +use shared::error::{TuliproxError}; +use shared::model::ConfigDto; +use crate::model::{macros, ConfigApi, ReverseProxyConfig, ScheduleConfig}; +use crate::model::{HdHomeRunConfig, IpCheckConfig, LogConfig, MessagingConfig, ProxyConfig, VideoConfig, WebUiConfig}; use crate::utils; - -#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize)] -pub enum FilterMode { - #[serde(rename = "discard")] - Discard, - #[serde(rename = "include")] - Include, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] -pub struct ConfigApi { - pub host: String, - pub port: u16, - #[serde(default)] - pub web_root: String, -} - -impl ConfigApi { - pub fn prepare(&mut self) { - if self.web_root.is_empty() { - self.web_root = String::from("./web"); - } - } -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone, Default)] pub struct Config { - #[serde(default)] pub threads: u8, pub api: ConfigApi, pub working_dir: String, - #[serde(default, skip_serializing_if = "Option::is_none")] pub backup_dir: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub user_config_dir: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub mapping_path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub custom_stream_response_path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub video: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub schedules: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] pub log: Option, - #[serde(default)] pub user_access_control: bool, - #[serde(default = "default_connect_timeout_secs")] pub connect_timeout_secs: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] pub sleep_timer_mins: Option, - #[serde(default)] pub update_on_boot: bool, - #[serde(default)] pub config_hot_reload: bool, - #[serde(default)] pub web_ui: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub messaging: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub reverse_proxy: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub hdhomerun: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub proxy: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub ipcheck: Option, - #[serde(skip)] - pub sources: SourcesConfig, - #[serde(skip)] - pub t_hdhomerun: Arc>, - #[serde(skip)] - pub t_api_proxy: Arc>, - #[serde(skip)] - pub t_config_path: String, - #[serde(skip)] - pub t_config_file_path: String, - #[serde(skip)] - pub t_sources_file_path: String, - #[serde(skip)] - pub t_mapping_file_path: String, - #[serde(skip)] - pub t_api_proxy_file_path: String, - #[serde(skip)] - pub t_custom_stream_response_path: Option, - #[serde(skip)] - pub file_locks: Arc, - #[serde(skip)] - pub t_custom_stream_response: Option, - #[serde(skip)] - pub t_access_token_secret: [u8; 32], - #[serde(skip)] - pub t_encrypt_secret: [u8; 16], } impl Config { - pub fn set_api_proxy(&self, api_proxy: Option>) -> Result<(), TuliproxError> { - self.t_api_proxy.store(api_proxy); - self.check_target_user() - } - - fn check_username(&self, output_username: Option<&str>, target_name: &str) -> Result<(), TuliproxError> { - if let Some(username) = output_username { - if let Some((_, config_target)) = self.get_target_for_username(username) { - if config_target.name != target_name { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "User:{username} does not belong to target: {}", target_name); - } - } else { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "User: {username} does not exist"); - } - Ok(()) - } else { - Ok(()) - } - } - fn check_target_user(&self) -> Result<(), TuliproxError> { - let check_homerun = self.t_hdhomerun.load().as_ref().is_some_and(|h| h.enabled); - for source in &self.sources.sources { - for target in &source.targets { - for output in &target.output { - match output { - TargetOutput::Xtream(_) | TargetOutput::M3u(_) => {} - TargetOutput::Strm(strm_output) => { - self.check_username(strm_output.username.as_deref(), &target.name)?; - } - TargetOutput::HdHomeRun(hdhomerun_output) => { - if check_homerun { - let hdhr_name = &hdhomerun_output.device; - self.check_username(Some(&hdhomerun_output.username), &target.name)?; - if let Some(old_hdhomerun) = self.t_hdhomerun.load().clone() { - let mut hdhomerun = (*old_hdhomerun).clone(); - for device in &mut hdhomerun.devices { - if &device.name == hdhr_name { - device.t_username.clone_from(&hdhomerun_output.username); - device.t_enabled = true; - } - } - self.t_hdhomerun.store(Some(Arc::new(hdhomerun))); - } - } - } - } - } - } - } - - let guard = self.t_hdhomerun.load(); - if let Some(hdhomerun) = &*guard { - for device in &hdhomerun.devices { - if !device.t_enabled { - debug!("HdHomeRun device '{}' has no username and will be disabled", device.name); - } - } - } - Ok(()) - } - - pub fn is_reverse_proxy_resource_rewrite_enabled(&self) -> bool { - self.reverse_proxy.as_ref().is_none_or(|r| !r.resource_rewrite_disabled) - } - - fn intern_get_target_for_user(&self, user_target: Option<(ProxyUserCredentials, String)>) -> Option<(ProxyUserCredentials, &ConfigTarget)> { - match user_target { - Some((user, target_name)) => { - for source in &self.sources.sources { - for target in &source.targets { - if target_name.eq_ignore_ascii_case(&target.name) { - return Some((user, target)); - } - } - } - None - } - None => None - } - } - - pub fn get_inputs_for_target(&self, target_name: &str) -> Option> { - for source in &self.sources.sources { - if let Some(cfg) = source.get_inputs_for_target(target_name) { - return Some(cfg); - } - } - None - } - - pub fn get_target_for_username(&self, username: &str) -> Option<(ProxyUserCredentials, &ConfigTarget)> { - if let Some(credentials) = self.get_user_credentials(username) { - return self.t_api_proxy.load().as_ref() - .and_then(|api_proxy| self.intern_get_target_for_user(api_proxy.get_target_name(&credentials.username, &credentials.password))); - } - None - } - - pub fn get_target_for_user(&self, username: &str, password: &str) -> Option<(ProxyUserCredentials, &ConfigTarget)> { - self.t_api_proxy.load().as_ref().and_then(|api_proxy| self.intern_get_target_for_user(api_proxy.get_target_name(username, password))) - } - - pub fn get_target_for_user_by_token(&self, token: &str) -> Option<(ProxyUserCredentials, &ConfigTarget)> { - self.t_api_proxy.load().as_ref().as_ref().and_then(|api_proxy| self.intern_get_target_for_user(api_proxy.get_target_name_by_token(token))) - } - - pub fn get_user_credentials(&self, username: &str) -> Option { - self.t_api_proxy.load().as_ref().as_ref().and_then(|api_proxy| api_proxy.get_user_credentials(username)) - } - - pub fn get_input_by_name(&self, input_name: &str) -> Option<&ConfigInput> { - for source in &self.sources.sources { - for input in &source.inputs { - if input.name == input_name { - return Some(input); - } - } - } - None - } - - pub fn get_input_options_by_name(&self, input_name: &str) -> Option<&ConfigInputOptions> { - for source in &self.sources.sources { - for input in &source.inputs { - if input.name == input_name { - return input.options.as_ref(); - } - } - } - None - } - - pub fn get_input_by_id(&self, input_id: u16) -> Option<&ConfigInput> { - for source in &self.sources.sources { - for input in &source.inputs { - if input.id == input_id { - return Some(input); - } - } - } - None - } - - pub fn get_target_by_id(&self, target_id: u16) -> Option<&ConfigTarget> { - self.sources.get_target_by_id(target_id) - } - - pub fn set_mappings(&self, mappings_cfg: &Mappings) { - for source in &self.sources.sources { - for target in &source.targets { - if let Some(mapping_ids) = &target.mapping { - let mut target_mappings = Vec::with_capacity(128); - for mapping_id in mapping_ids { - let mapping = mappings_cfg.get_mapping(mapping_id); - if let Some(mappings) = mapping { - target_mappings.push(mappings); - } - } - target.t_mapping.store(if target_mappings.is_empty() { None } else { Some(Arc::new(target_mappings)) }); - } - } - } - } - - fn check_unique_input_names(&mut self) -> Result<(), TuliproxError> { - let mut seen_names = HashSet::new(); - for source in &mut self.sources.sources { - for input in &source.inputs { - let input_name = input.name.trim().to_string(); - if input_name.is_empty() { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "input name required"); - } - if seen_names.contains(input_name.as_str()) { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "input names should be unique: {}", input_name); - } - seen_names.insert(input_name); - if let Some(aliases) = &input.aliases { - for alias in aliases { - let input_name = alias.name.trim().to_string(); - if input_name.is_empty() { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "input name required"); - } - if seen_names.contains(input_name.as_str()) { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "input names should be unique: {}", input_name); - } - seen_names.insert(input_name); - } - } - } - } - Ok(()) - } - - - fn check_scheduled_targets(&mut self, target_names: &HashSet) -> Result<(), TuliproxError> { - if let Some(schedules) = &self.schedules { - for schedule in schedules { - if let Some(targets) = &schedule.targets { - for target_name in targets { - if !target_names.contains(target_name) { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Unknown target name in scheduler: {}", target_name); - } - } - } - } - } - Ok(()) - } - - /** - * if `include_computed` set to true for `app_state` - */ - pub fn prepare(&mut self, include_computed: bool) -> Result<(), TuliproxError> { + pub fn prepare(&mut self, config_path: &str) -> Result<(), TuliproxError> { let work_dir = &self.working_dir; self.working_dir = utils::resolve_directory_path(work_dir); - if let Some(mapping_path) = &self.mapping_path { - self.t_mapping_file_path = mapping_path.to_string(); - } - if let Some(mins) = self.sleep_timer_mins { - if mins == 0 { - return Err(TuliproxError::new(TuliproxErrorKind::Info, "`sleep_timer_mins` must be > 0 when specified".to_string())); - } - } - - if include_computed { - self.t_access_token_secret = generate_secret(); - self.t_encrypt_secret = <&[u8] as TryInto<[u8; 16]>>::try_into(&generate_secret()[0..16]).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, err.to_string()))?; - self.prepare_custom_stream_response(); - } self.prepare_directories(); - if let Some(reverse_proxy) = self.reverse_proxy.as_mut() { - reverse_proxy.prepare(&self.working_dir)?; - } - if let Some(proxy) = &mut self.proxy { - proxy.prepare()?; - } - if let Some(ipcheck) = self.ipcheck.as_mut() { - ipcheck.prepare()?; - } - self.prepare_hdhomerun()?; - self.api.prepare(); self.prepare_api_web_root(); - self.sources.prepare(include_computed)?; - let target_names = self.sources.check_unique_target_names()?; - self.check_scheduled_targets(&target_names)?; - self.check_unique_input_names()?; - self.prepare_video_config()?; - self.prepare_web()?; + if let Some(ref mut webui) = &mut self.web_ui { + webui.prepare(config_path)?; + } Ok(()) } + fn prepare_directories(&mut self) { fn set_directory(path: &mut Option, default_subdir: &str, working_dir: &str) { *path = Some(match path.as_ref() { @@ -399,119 +58,40 @@ impl Config { set_directory(&mut self.user_config_dir, "user_config", &self.working_dir); } - fn prepare_hdhomerun(&mut self) -> Result<(), TuliproxError> { - if let Some(old_hdhomerun) = &self.hdhomerun { - let mut hdhomerun = (*old_hdhomerun).clone(); - if hdhomerun.enabled { - hdhomerun.prepare(self.api.port)?; - } - self.t_hdhomerun.store(Some(Arc::new(hdhomerun))); - } - Ok(()) - } - - fn prepare_web(&mut self) -> Result<(), TuliproxError> { - if let Some(web_ui_config) = self.web_ui.as_mut() { - web_ui_config.prepare(&self.t_config_path)?; - } - Ok(()) - } - - fn prepare_video_config(&mut self) -> Result<(), TuliproxError> { - match &mut self.video { - None => { - self.video = Some(VideoConfig { - extensions: vec!["mkv".to_string(), "avi".to_string(), "mp4".to_string()], - download: None, - web_search: None, - }); - } - Some(video) => { - match video.prepare() { - Ok(()) => {} - Err(err) => return Err(err) - } - } - } - Ok(()) - } - - fn prepare_custom_stream_response(&mut self) { - if let Some(custom_stream_response_path) = self.custom_stream_response_path.as_ref() { - fn load_and_set_file(file_path: &Path) -> Option { - if file_path.exists() { - // Enforce maximum file size (10 MB) - if let Ok(meta) = std::fs::metadata(file_path) { - const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024; - if meta.len() > MAX_RESPONSE_SIZE { - error!("Custom stream response file too large ({} bytes): {}", - meta.len(), file_path.display()); - return None; - } - } - // Quick MPEG-TS sync-byte check (0x47) - if let Ok(mut f) = File::open(file_path) { - let mut buf = [0u8; 1]; - if f.read_exact(&mut buf).is_err() || buf[0] != 0x47 { - error!("Invalid MPEG-TS file: {}", file_path.display()); - return None; - } - } - - match utils::read_file_as_bytes(&PathBuf::from(&file_path)) { - Ok(data) => Some(TransportStreamBuffer::new(data, )), - Err(err) => { - error!("Failed to load a resource file: {} {err}", file_path.display()); - None - } - } - } else { - None - } - } - - let path = PathBuf::from(custom_stream_response_path); - let path = utils::make_path_absolute(&path, &self.working_dir); - self.t_custom_stream_response_path = Some(path.to_string_lossy().to_string()); - let channel_unavailable = load_and_set_file(&path.join(CHANNEL_UNAVAILABLE)); - let user_connections_exhausted = load_and_set_file(&path.join(USER_CONNECTIONS_EXHAUSTED)); - let provider_connections_exhausted = load_and_set_file(&path.join(PROVIDER_CONNECTIONS_EXHAUSTED)); - let user_account_expired = load_and_set_file(&path.join(USER_ACCOUNT_EXPIRED)); - self.t_custom_stream_response = Some(CustomStreamResponse { - channel_unavailable, - user_connections_exhausted, - provider_connections_exhausted, - user_account_expired, - }); - } - } - fn prepare_api_web_root(&mut self) { if !self.api.web_root.is_empty() { self.api.web_root = utils::make_absolute_path(&self.api.web_root, &self.working_dir); } } - /// # Panics - /// - /// Will panic if default server invalid - pub fn get_server_info(&self, server_info_name: &str) -> ApiProxyServerInfo { - let guard = self.t_api_proxy.load(); - if let Ok(api_proxy) = guard.as_ref().ok_or_else(|| { - TuliproxError::new(TuliproxErrorKind::Info, "API proxy config not loaded".to_string()) - }) { - let server_info_list = api_proxy.server.clone(); - server_info_list.iter().find(|c| c.name.eq(server_info_name)).map_or_else(|| server_info_list.first().unwrap().clone(), Clone::clone) - } else { - panic!("ApiProxyServer info not found"); - } - } - - pub fn get_user_server_info(&self, user: &ProxyUserCredentials) -> ApiProxyServerInfo { - let server_info_name = user.server.as_ref().map_or("default", |server_name| server_name.as_str()); - self.get_server_info(server_info_name) - } - } +macros::from_impl!(Config); +impl From<&ConfigDto> for Config { + fn from(dto: &ConfigDto) -> Self { + Config { + threads: 0, + api: ConfigApi::from(&dto.api), + working_dir: dto.working_dir.to_string(), + backup_dir: dto.backup_dir.clone(), + user_config_dir: dto.user_config_dir.clone(), + mapping_path: dto.mapping_path.clone(), + custom_stream_response_path: dto.custom_stream_response_path.clone(), + video: dto.video.as_ref().map(Into::into), + schedules: dto.schedules.as_ref().map(|s| s.iter().map(Into::into).collect()), + log: dto.log.as_ref().map(Into::into), + user_access_control: dto.user_access_control, + connect_timeout_secs: dto.connect_timeout_secs, + sleep_timer_mins: dto.sleep_timer_mins, + update_on_boot: dto.update_on_boot, + config_hot_reload: dto.config_hot_reload, + web_ui: dto.web_ui.as_ref().map(Into::into), + messaging: dto.messaging.as_ref().map(Into::into), + reverse_proxy: dto.reverse_proxy.as_ref().map(Into::into), + hdhomerun: dto.hdhomerun.as_ref().map(Into::into), + proxy: dto.proxy.as_ref().map(Into::into), + ipcheck: dto.ipcheck.as_ref().map(Into::into), + } + } +} \ No newline at end of file diff --git a/backend/src/model/config/cache.rs b/backend/src/model/config/cache.rs index 3ddf1195c..d0fa721e7 100644 --- a/backend/src/model/config/cache.rs +++ b/backend/src/model/config/cache.rs @@ -1,49 +1,50 @@ -use std::path::PathBuf; -use log::error; -use path_clean::PathClean; -use shared::error::{info_err, TuliproxError, TuliproxErrorKind}; +use shared::model::CacheConfigDto; use shared::utils::parse_size_base_2; +use crate::model::macros; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct CacheConfig { - #[serde(default)] pub enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub size: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dir: Option, - #[serde(skip)] - pub t_size: usize, + pub dir: String, + pub size: usize, + pub size_str: Option, } -impl CacheConfig { - pub(crate) fn prepare(&mut self, working_dir: &str) -> Result<(), TuliproxError>{ - if self.enabled { - let work_path = PathBuf::from(working_dir); - if self.dir.is_none() { - self.dir = Some(work_path.join("cache").to_string_lossy().to_string()); - } else { - let mut cache_dir = self.dir.as_ref().unwrap().to_string(); - if PathBuf::from(&cache_dir).is_relative() { - cache_dir = work_path.join(&cache_dir).clean().to_string_lossy().to_string(); - } - self.dir = Some(cache_dir.to_string()); - } - match self.size.as_ref() { - None => self.t_size = 1024, - Some(val) => match parse_size_base_2(val) { - Ok(size) => match usize::try_from(size) { - Ok(s) => self.t_size = s, - Err(err) => { - self.t_size = 0; - error!("Cache size could not be determined. Setting to 0. {err}"); - } - }, - Err(err) => { return Err(info_err!(format!("Failed to read cache size: {err}"))) } +macros::from_impl!(CacheConfig); +impl From<&CacheConfigDto> for CacheConfig { + fn from(dto: &CacheConfigDto) -> Self { + Self { + enabled: dto.enabled, + // Dto prepare should have set the right path + dir: dto.dir.as_ref().map_or_else(Default::default, std::string::ToString::to_string), + size_str: dto.size.clone(), + size: get_size(dto) + } + } +} + +impl From<&CacheConfig> for CacheConfigDto { + fn from(instance: &CacheConfig) -> Self { + Self { + enabled: instance.enabled, + // Dto prepare should have set the right path + dir: Some(instance.dir.to_string()), + size: instance.size_str.clone(), + } + } +} + +fn get_size(dto: &CacheConfigDto) -> usize { + // we assume that the previous dto check discarded all problems + match dto.size.as_ref() { + None => return 1024, + Some(val) => { + if let Ok(size) = parse_size_base_2(val) { + if let Ok(value) = usize::try_from(size) { + return value; } } } - Ok(()) } -} \ No newline at end of file + 0 +} diff --git a/backend/src/model/config/epg.rs b/backend/src/model/config/epg.rs index 74cbe3043..9c0663dd3 100644 --- a/backend/src/model/config/epg.rs +++ b/backend/src/model/config/epg.rs @@ -1,209 +1,36 @@ -use shared::error::{create_tuliprox_error_result, info_err, TuliproxError, TuliproxErrorKind}; -use shared::utils::CONSTANTS; -use log::warn; -use regex::Regex; +use shared::model::{EpgConfigDto, EpgSourceDto}; +use crate::model::{macros, EpgSmartMatchConfig}; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct EpgSource { - pub(crate) url: String, - #[serde(default)] + pub url: String, pub priority: i16, - #[serde(default)] pub logo_override: bool, } -impl EpgSource { - pub fn prepare(&mut self) { - self.url = self.url.trim().to_string(); - } - - pub fn is_valid(&self) -> bool { - !self.url.is_empty() - } -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "lowercase")] -pub enum EpgNamePrefix { - #[default] - Ignore, - Suffix(String), - Prefix(String), -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -pub struct EpgSmartMatchConfig { - #[serde(default)] - pub enabled: bool, - pub normalize_regex: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub strip: Option>, - #[serde(default)] - pub name_prefix: EpgNamePrefix, - #[serde(default)] - pub name_prefix_separator: Option>, - #[serde(default)] - pub fuzzy_matching: bool, - #[serde(default)] - pub match_threshold: u16, - #[serde(default)] - pub best_match_threshold: u16, - #[serde(skip)] - pub t_strip: Vec, - #[serde(skip)] - pub t_normalize_regex: Option, - #[serde(skip)] - pub t_name_prefix_separator: Vec, - -} - -impl EpgSmartMatchConfig { - /// Creates a new enabled `EpgSmartMatchConfig` with default settings and prepares it. - /// - /// Returns an error if preparation fails. - /// - /// # Examples - /// - /// ``` - /// let config = EpgSmartMatchConfig::new().unwrap(); - /// assert!(config.enabled); - /// ``` - pub fn new() -> Result { - let mut this = Self { enabled: true, ..Self::default() }; - this.prepare()?; - Ok(this) - } - - /// # Panics - /// - /// Prepares the EPG smart match configuration by validating thresholds, compiling normalization regex, and setting default values as needed. - /// - /// Adjusts match thresholds to valid ranges, compiles the normalization regex, and sets default strip values and name prefix separators if not provided. Returns an error if the normalization regex is invalid. - /// - /// # Returns - /// - /// `Ok(())` if preparation succeeds, or an `TuliproxError` if regex compilation fails. - pub fn prepare(&mut self) -> Result<(), TuliproxError> { - if !self.enabled { - return Ok(()); +macros::from_impl!(EpgSource); +impl From<&EpgSourceDto> for EpgSource { + fn from(dto: &EpgSourceDto) -> Self { + Self { + url: dto.url.to_string(), + priority: dto.priority, + logo_override: dto.logo_override, } - - self.t_name_prefix_separator = match &self.name_prefix_separator { - None => vec![':', '|', '-'], - Some(list) => list.clone(), - }; - - if self.match_threshold == 0 { - self.match_threshold = 80; - } else if self.match_threshold < 10 { - warn!("match_threshold is less than 10%, setting to 10%"); - self.match_threshold = 10; - } else if self.match_threshold > 100 { - warn!("match_threshold is more than 100%, setting to 80%"); - self.match_threshold = 100; - } - - if self.best_match_threshold == 0 || self.best_match_threshold > 100 || self.best_match_threshold < self.match_threshold { - self.best_match_threshold = 99; - } - - self.t_normalize_regex = match self.normalize_regex.as_ref() { - None => Some(CONSTANTS.re_epg_normalize.clone()), - Some(regstr) => { - let re = regex::Regex::new(regstr.as_str()); - if re.is_err() { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "cant parse regex: {}", regstr); - } - Some(re.unwrap()) - } - }; - - match &self.strip { - Some(list) => self.t_strip = list.iter().map(|s| s.to_lowercase()).collect(), - None => self.t_strip = ["3840p", "uhd", "fhd", "hd", "sd", "4k", "plus", "raw", "full hd"].iter().map(std::string::ToString::to_string).collect(), - } - Ok(()) } } -impl Default for EpgSmartMatchConfig { - fn default() -> Self { - let mut instance = EpgSmartMatchConfig { - enabled: false, - normalize_regex: None, - strip: None, - name_prefix: EpgNamePrefix::default(), - name_prefix_separator: None, - fuzzy_matching: false, - match_threshold: 0, - best_match_threshold: 0, - t_strip: Vec::default(), - t_normalize_regex: None, - t_name_prefix_separator: Vec::default(), - }; - let _ = instance.prepare(); - instance - } -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct EpgConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sources: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] + pub sources: Vec, pub smart_match: Option, - #[serde(skip)] - pub t_sources: Vec, - #[serde(skip)] - pub t_smart_match: EpgSmartMatchConfig, } -impl EpgConfig { - pub fn prepare(&mut self, create_auto_url: F, include_computed: bool) -> Result<(), TuliproxError> - where - F: Fn() -> Result, - { - if include_computed { - self.t_sources = Vec::new(); - if let Some(epg_sources) = self.sources.as_mut() { - for epg_source in epg_sources { - epg_source.prepare(); - if epg_source.is_valid() { - if include_computed && epg_source.url.eq_ignore_ascii_case("auto") { - let auto_url = create_auto_url(); - match auto_url { - Ok(provider_url) => { - self.t_sources.push(EpgSource { - url: provider_url, - priority: epg_source.priority, - logo_override: epg_source.logo_override, - }); - } - Err(err) => return Err(info_err!(err)) - } - } else { - self.t_sources.push(epg_source.clone()); - } - } - } - } - - self.t_smart_match = match self.smart_match.as_mut() { - None => { - let mut normalize: EpgSmartMatchConfig = EpgSmartMatchConfig::default(); - normalize.prepare()?; - normalize - } - Some(normalize_cfg) => { - let mut normalize: EpgSmartMatchConfig = normalize_cfg.clone(); - normalize.prepare()?; - normalize - } - }; +macros::from_impl!(EpgConfig); +impl From<&EpgConfigDto> for EpgConfig { + fn from(dto: &EpgConfigDto) -> Self { + Self { + sources: dto.t_sources.iter().map(Into::into).collect(), + smart_match: dto.smart_match.as_ref().map(Into::into), } - Ok(()) } -} \ No newline at end of file +} diff --git a/backend/src/model/config/epg_smart_match.rs b/backend/src/model/config/epg_smart_match.rs new file mode 100644 index 000000000..298dc11de --- /dev/null +++ b/backend/src/model/config/epg_smart_match.rs @@ -0,0 +1,42 @@ +use shared::utils::CONSTANTS; +use regex::Regex; +use shared::model::{EpgNamePrefix, EpgSmartMatchConfigDto}; +use crate::model::macros; + +#[derive(Debug, Clone)] +pub struct EpgSmartMatchConfig { + pub enabled: bool, + pub normalize_regex: Regex, + pub strip: Vec, + pub name_prefix: EpgNamePrefix, + pub name_prefix_separator: Vec, + pub fuzzy_matching: bool, + pub match_threshold: u16, + pub best_match_threshold: u16, + +} + +macros::from_impl!(EpgSmartMatchConfig); +impl From<&EpgSmartMatchConfigDto> for EpgSmartMatchConfig { + fn from(dto: &EpgSmartMatchConfigDto) -> Self { + Self { + enabled: dto.enabled, + normalize_regex: match &dto.normalize_regex { + Some(regex_str) => regex::Regex::new(regex_str).unwrap_or_else(|_| CONSTANTS.re_epg_normalize.clone()), + None => CONSTANTS.re_epg_normalize.clone(), + }, + strip: match &dto.strip { + Some(list) => list.iter().map(|s| s.to_lowercase()).collect(), + None => ["3840p", "uhd", "fhd", "hd", "sd", "4k", "plus", "raw", "full hd"].iter().map(std::string::ToString::to_string).collect(), + }, + name_prefix: dto.name_prefix.clone(), + name_prefix_separator: match &dto.name_prefix_separator { + None => vec![':', '|', '-'], + Some(list) => list.clone(), + }, + fuzzy_matching: dto.fuzzy_matching, + match_threshold: dto.match_threshold, + best_match_threshold: dto.best_match_threshold, + } + } +} diff --git a/backend/src/model/config/hdhomerun.rs b/backend/src/model/config/hdhomerun.rs index f74e65cf5..b38e38ce1 100644 --- a/backend/src/model/config/hdhomerun.rs +++ b/backend/src/model/config/hdhomerun.rs @@ -1,103 +1,59 @@ -use std::collections::HashSet; -use log::warn; -use shared::error::{TuliproxError, TuliproxErrorKind, create_tuliprox_error_result}; -fn default_friendly_name() -> String { String::from("TuliproxTV") } -fn default_manufacturer() -> String { String::from("Silicondust") } -fn default_model_name() -> String { String::from("HDTC-2US") } -fn default_firmware_name() -> String { String::from("hdhomeruntc_atsc") } -fn default_firmware_version() -> String { String::from("20170930") } -fn default_device_type() -> String { String::from("urn:schemas-upnp-org:device:MediaServer:1") } -fn default_device_udn() -> String { String::from("uuid:12345678-90ab-cdef-1234-567890abcdef::urn:dial-multicast:com.silicondust.hdhomerun") } +use shared::model::{HdHomeRunConfigDto, HdHomeRunDeviceConfigDto}; +use crate::model::macros; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct HdHomeRunDeviceConfig { - #[serde(default = "default_friendly_name")] pub friendly_name: String, - #[serde(default = "default_manufacturer")] pub manufacturer: String, - #[serde(default = "default_model_name")] pub model_name: String, - #[serde(default = "default_model_name")] pub model_number: String, - #[serde(default = "default_firmware_name")] pub firmware_name: String, - #[serde(default = "default_firmware_version")] pub firmware_version: String, // pub device_auth: String, - #[serde(default = "default_device_type")] pub device_type: String, - #[serde(default = "default_device_udn")] pub device_udn: String, pub name: String, - #[serde(default)] pub port: u16, - #[serde(default)] pub tuner_count: u8, - #[serde(skip)] pub t_username: String, - #[serde(skip)] pub t_enabled: bool, } -impl HdHomeRunDeviceConfig { - pub fn prepare(&mut self, device_num: u8) { - self.name = self.name.trim().to_string(); - if self.name.is_empty() { - self.name = format!("device{device_num}"); - warn!("Device name empty, assigned new name: {}", self.name); - } - - if self.tuner_count == 0 { - self.tuner_count = 1; - } - - if device_num > 0 && self.friendly_name == default_friendly_name() { - self.friendly_name = format!("{} {}", self.friendly_name, device_num); - } - if self.device_udn == default_device_udn() { - self.device_udn = format!("{}:{}", self.device_udn, device_num+1); +macros::from_impl!(HdHomeRunDeviceConfig); +impl From<&HdHomeRunDeviceConfigDto> for HdHomeRunDeviceConfig { + fn from(dto: &HdHomeRunDeviceConfigDto) -> Self { + Self { + friendly_name: dto.friendly_name.to_string(), + manufacturer: dto.manufacturer.to_string(), + model_name: dto.model_name.to_string(), + model_number: dto.model_number.to_string(), + firmware_name: dto.firmware_name.to_string(), + firmware_version: dto.firmware_version.to_string(), + device_type: dto.device_type.to_string(), + device_udn: dto.device_udn.to_string(), + name: dto.name.to_string(), + port: dto.port, + tuner_count: dto.tuner_count, + t_username: String::new(), + t_enabled: false, } } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct HdHomeRunConfig { - #[serde(default)] pub enabled: bool, - #[serde(default)] pub auth: bool, pub devices: Vec, } -impl HdHomeRunConfig { - pub fn prepare(&mut self, api_port: u16) -> Result<(), TuliproxError> { - let mut names = HashSet::new(); - let mut ports = HashSet::new(); - ports.insert(api_port); - for (device_num, device) in (0_u8..).zip(self.devices.iter_mut()) { - device.prepare(device_num); - if names.contains(&device.name) { - names.insert(&device.name); - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "HdHomeRun duplicate device name {}", device.name); - } - if device.port > 0 && ports.contains(&device.port) { - ports.insert(device.port); - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "HdHomeRun duplicate port {}", device.port); - } +macros::from_impl!(HdHomeRunConfig); +impl From<&HdHomeRunConfigDto> for HdHomeRunConfig { + fn from(dto: &HdHomeRunConfigDto) -> Self { + Self { + enabled: dto.enabled, + auth: dto.auth, + devices: dto.devices.iter().map(Into::into).collect(), } - let mut current_port = api_port + 1; - for device in &mut self.devices { - if device.port == 0 { - while ports.contains(¤t_port) { - current_port += 1; - } - device.port = current_port; - current_port += 1; - } - } - - Ok(()) - } + } } \ No newline at end of file diff --git a/backend/src/model/config/healthcheck.rs b/backend/src/model/config/healthcheck.rs index 98af20000..06591d1ed 100644 --- a/backend/src/model/config/healthcheck.rs +++ b/backend/src/model/config/healthcheck.rs @@ -1,6 +1,6 @@ -use crate::model::ConfigApi; +use shared::model::ConfigApiDto; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct HealthcheckConfig { - pub api: ConfigApi, + pub api: ConfigApiDto, } diff --git a/backend/src/model/config/input.rs b/backend/src/model/config/input.rs index 0cc5db511..ad5178ff2 100644 --- a/backend/src/model/config/input.rs +++ b/backend/src/model/config/input.rs @@ -1,145 +1,35 @@ -use shared::error::{create_tuliprox_error_result, handle_tuliprox_error_result_list, info_err, TuliproxError, TuliproxErrorKind}; -use crate::model::{EpgConfig}; -use shared::utils::default_as_true; -use shared::utils::get_trimmed_string; -use crate::utils::request::{get_base_url_from_str, get_credentials_from_url, get_credentials_from_url_str, sanitize_sensitive_info}; -use enum_iterator::Sequence; -use log::{debug}; -use std::collections::{HashMap, HashSet}; -use std::fmt::Display; -use std::str::FromStr; +use shared::error::{TuliproxError, TuliproxErrorKind}; +use crate::model::{macros, EpgConfig}; +use shared::utils::{get_base_url_from_str, get_credentials_from_url}; +use std::collections::{HashMap}; use url::Url; +use shared::model::{ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, InputFetchMethod, InputType}; use crate::utils; -macro_rules! check_input_credentials { - ($this:ident, $input_type:expr) => { - match $input_type { - InputType::M3u | InputType::M3uBatch => { - if $this.username.is_some() || $this.password.is_some() { - debug!("for input type m3u: username and password are ignored"); - } - if $this.username.is_none() && $this.password.is_none() { - let (username, password) = get_credentials_from_url_str(&$this.url); - $this.username = username; - $this.password = password; - } - } - InputType::Xtream | InputType::XtreamBatch => { - if $this.username.is_none() || $this.password.is_none() { - return Err(info_err!("for input type xtream: username and password are mandatory".to_string())); - } - } - } - }; -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -pub struct InputAffix { - pub field: String, - pub value: String, -} - -#[derive(Debug,Copy,Clone,serde::Serialize,serde::Deserialize,Sequence,PartialEq,Eq,Default)] -pub enum InputType { - #[serde(rename = "m3u")] - #[default] - M3u, - #[serde(rename = "xtream")] - Xtream, - #[serde(rename = "m3u_batch")] - M3uBatch, - #[serde(rename = "xtream_batch")] - XtreamBatch, -} - -impl InputType { - const M3U: &'static str = "m3u"; - const XTREAM: &'static str = "xtream"; - const M3U_BATCH: &'static str = "m3u_batch"; - const XTREAM_BATCH: &'static str = "xtream_batch"; -} - -impl Display for InputType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", match self { - Self::M3u => Self::M3U, - Self::Xtream => Self::XTREAM, - Self::M3uBatch => Self::M3U_BATCH, - Self::XtreamBatch => Self::XTREAM_BATCH, - }) - } -} - -impl FromStr for InputType { - type Err = TuliproxError; - - fn from_str(s: &str) -> Result { - if s.eq(Self::M3U) { - Ok(Self::M3u) - } else if s.eq(Self::XTREAM) { - Ok(Self::Xtream) - } else if s.eq(Self::M3U_BATCH) { - Ok(Self::M3uBatch) - } else if s.eq(Self::XTREAM_BATCH) { - Ok(Self::XtreamBatch) - } else { - create_tuliprox_error_result!(TuliproxErrorKind::Info, "Unknown InputType: {}", s) - } - } -} - -#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)] -pub enum InputFetchMethod { - #[default] - GET, - POST, -} - -impl InputFetchMethod { - const GET_METHOD: &'static str = "GET"; - const POST_METHOD: &'static str = "POST"; -} - -impl Display for InputFetchMethod { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", match self { - Self::GET => Self::GET_METHOD, - Self::POST => Self::POST_METHOD, - }) - } -} - -impl FromStr for InputFetchMethod { - type Err = TuliproxError; - - fn from_str(s: &str) -> Result { - if s.eq(Self::GET_METHOD) { - Ok(Self::GET) - } else if s.eq(Self::POST_METHOD) { - Ok(Self::POST) - } else { - create_tuliprox_error_result!(TuliproxErrorKind::Info, "Unknown Fetch Method: {}", s) - } - } -} #[allow(clippy::struct_excessive_bools)] -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct ConfigInputOptions { - #[serde(default)] pub xtream_skip_live: bool, - #[serde(default)] pub xtream_skip_vod: bool, - #[serde(default)] pub xtream_skip_series: bool, - #[serde(default = "default_as_true")] pub xtream_live_stream_use_prefix: bool, - #[serde(default)] pub xtream_live_stream_without_extension: bool, } +macros::from_impl!(ConfigInputOptions); +impl From<&ConfigInputOptionsDto> for ConfigInputOptions { + fn from(dto: &ConfigInputOptionsDto) -> Self { + Self { + xtream_skip_live: dto.xtream_skip_live, + xtream_skip_vod: dto.xtream_skip_vod, + xtream_skip_series: dto.xtream_skip_series, + xtream_live_stream_use_prefix: dto.xtream_live_stream_use_prefix, + xtream_live_stream_without_extension: dto.xtream_live_stream_without_extension, + } + } +} + pub struct InputUserInfo { pub base_url: String, pub username: String, @@ -173,143 +63,75 @@ impl InputUserInfo { } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct ConfigInputAlias { - #[serde(skip)] pub id: u16, pub name: String, pub url: String, pub username: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub password: Option, - #[serde(default)] pub priority: i16, - #[serde(default)] pub max_connections: u16, - #[serde(skip)] - pub t_base_url: String, } - -impl ConfigInputAlias { - pub fn prepare(&mut self, index: u16, input_type: &InputType) -> Result<(), TuliproxError> { - self.id = index; - self.name = self.name.trim().to_string(); - if self.name.is_empty() { - return Err(info_err!("name for input is mandatory".to_string())); +macros::from_impl!(ConfigInputAlias); +impl From<&ConfigInputAliasDto> for ConfigInputAlias { + fn from(dto: &ConfigInputAliasDto) -> Self { + Self { + id: dto.id, + name: dto.name.to_string(), + url: get_base_url_from_str(&dto.url).map_or_else(|| dto.url.to_string(), |base_url| base_url), + username: dto.username.clone(), + password: dto.password.clone(), + priority: dto.priority, + max_connections: dto.max_connections, } - self.url = self.url.trim().to_string(); - if self.url.is_empty() { - return Err(info_err!("url for input is mandatory".to_string())); - } - if let Some(base_url) = get_base_url_from_str(&self.url) { - self.t_base_url = base_url; - } - self.username = get_trimmed_string(&self.username); - self.password = get_trimmed_string(&self.password); - check_input_credentials!(self, input_type); - - Ok(()) } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] + +#[derive(Debug, Clone, Default)] pub struct ConfigInput { - #[serde(skip)] pub id: u16, pub name: String, - #[serde(default, rename = "type")] pub input_type: InputType, - #[serde(default)] pub headers: HashMap, pub url: String, - #[serde(default, skip_serializing_if = "Option::is_none")] pub epg: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub username: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub password: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub persist: Option, - #[serde(default = "default_as_true")] pub enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] pub options: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub aliases: Option>, - #[serde(default)] pub priority: i16, - #[serde(default)] pub max_connections: u16, - #[serde(default)] pub method: InputFetchMethod, - #[serde(skip)] - pub t_base_url: String, } impl ConfigInput { - #[allow(clippy::cast_possible_truncation)] - pub fn prepare(&mut self, index: u16, include_computed: bool) -> Result { - self.id = index; - self.check_url()?; - self.prepare_batch()?; - self.name = self.name.trim().to_string(); - if self.name.is_empty() { - return Err(info_err!("name for input is mandatory".to_string())); - } - - self.username = get_trimmed_string(&self.username); - self.password = get_trimmed_string(&self.password); - check_input_credentials!(self, self.input_type); - self.persist = get_trimmed_string(&self.persist); - if let Some(base_url) = get_base_url_from_str(&self.url) { - self.t_base_url = base_url; - } - - if let Some(epg) = self.epg.as_mut() { - let create_auto_url = || { - let (username, password) = if self.username.is_none() || self.password.is_none() { - get_credentials_from_url_str(&self.url) - } else { - (self.username.clone(), self.password.clone()) - }; - - if username.is_none() || password.is_none() { - Err(format!("auto_epg is enabled for input {}, but no credentials could be extracted", self.name)) - } else if !self.t_base_url.is_empty() { - let provider_epg_url = format!("{}/xmltv.php?username={}&password={}", self.t_base_url, username.unwrap_or_default(), password.unwrap_or_default()); - Ok(provider_epg_url) - } else { - Err(format!("auto_epg is enabled for input {}, but url could not be parsed {}", self.name, sanitize_sensitive_info(&self.url))) - } - }; - - epg.prepare(create_auto_url, include_computed)?; - epg.t_sources = { - let mut seen_urls = HashSet::new(); - epg.t_sources - .drain(..) - .filter(|src| seen_urls.insert(src.url.clone())) - .collect() - }; - } - - if let Some(aliases) = self.aliases.as_mut() { - let input_type = &self.input_type; - handle_tuliprox_error_result_list!(TuliproxErrorKind::Info, aliases.iter_mut().enumerate().map(|(idx, i)| i.prepare(index+1+(idx as u16), input_type))); - } - Ok(index + self.aliases.as_ref().map_or(0, std::vec::Vec::len) as u16) + pub fn prepare(&mut self) -> Result<(), TuliproxError> { + self.prepare_batch() } - fn check_url(&mut self) -> Result<(), TuliproxError> { - self.url = self.url.trim().to_string(); - if self.url.is_empty() { - return Err(info_err!("url for input is mandatory".to_string())); + pub fn get_user_info(&self) -> Option { + InputUserInfo::new(self.input_type, self.username.as_deref(), self.password.as_deref(), &self.url) + } + + pub fn get_matched_config_by_url<'a>(&'a self, url: &str) -> Option<(&'a str, Option<&'a String>, Option<&'a String>)> { + if url.starts_with(&self.url) { + return Some((&self.url, self.username.as_ref(), self.password.as_ref())); } - Ok(()) + + if let Some(aliases) = &self.aliases { + for alias in aliases { + if url.starts_with(&alias.url) { + return Some((&alias.url, alias.username.as_ref(), alias.password.as_ref())); + } + } + } + None } fn prepare_batch(&mut self) -> Result<(), TuliproxError> { @@ -352,23 +174,27 @@ impl ConfigInput { } Ok(()) } - - pub fn get_user_info(&self) -> Option { - InputUserInfo::new(self.input_type, self.username.as_deref(), self.password.as_deref(), &self.url) - } - - pub fn get_matched_config_by_url<'a>(&'a self, url: &str) -> Option<(&'a str, Option<&'a String>, Option<&'a String>)> { - if url.starts_with(&self.t_base_url) { - return Some((&self.t_base_url, self.username.as_ref(), self.password.as_ref())); - } - - if let Some(aliases) = &self.aliases { - for alias in aliases { - if url.starts_with(&alias.t_base_url) { - return Some((&alias.t_base_url, alias.username.as_ref(), alias.password.as_ref())); - } - } - } - None - } } + +macros::from_impl!(ConfigInput); +impl From<&ConfigInputDto> for ConfigInput { + fn from(dto: &ConfigInputDto) -> Self { + Self { + id: dto.id, + name: dto.name.to_string(), + input_type: dto.input_type, + headers: dto.headers.clone(), + url: get_base_url_from_str(&dto.url).map_or_else(|| dto.url.to_string(), |base_url| base_url), + epg: dto.epg.as_ref().map(std::convert::Into::into), + username: dto.username.clone(), + password: dto.password.clone(), + persist: dto.persist.clone(), + enabled: dto.enabled, + options: dto.options.as_ref().map(std::convert::Into::into), + aliases: dto.aliases.as_ref().map(|list| list.iter().map(std::convert::Into::into).collect()), + priority: dto.priority, + max_connections: dto.max_connections, + method: dto.method, + } + } +} \ No newline at end of file diff --git a/backend/src/model/config/ip_check.rs b/backend/src/model/config/ip_check.rs index 8701a1611..ad15794de 100644 --- a/backend/src/model/config/ip_check.rs +++ b/backend/src/model/config/ip_check.rs @@ -1,81 +1,36 @@ use regex::Regex; -use shared::error::{TuliproxError, TuliproxErrorKind}; +use shared::model::IpCheckConfigDto; +use crate::model::macros; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct IpCheckConfig { - /// URL that may return both IPv4 and IPv6 in one response - #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, - - /// Dedicated URL to fetch only IPv4 - #[serde(default, skip_serializing_if = "Option::is_none")] pub url_ipv4: Option, - - /// Dedicated URL to fetch only IPv6 - #[serde(default, skip_serializing_if = "Option::is_none")] pub url_ipv6: Option, - - /// Optional regex pattern to extract IPv4 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pattern_ipv4: Option, - - /// Optional regex pattern to extract IPv6 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pattern_ipv6: Option, - - #[serde(skip)] - pub t_pattern_ipv4: Option, - #[serde(skip)] - pub t_pattern_ipv6: Option, + pub pattern_ipv4: Option, + pub pattern_ipv6: Option, } -impl IpCheckConfig { - pub fn prepare(&mut self) -> Result<(), TuliproxError> { - if let Some(url) = &self.url { - let url = url.trim(); - if url.is_empty() { - self.url = None; - } else { - self.url = Some(url.to_owned()); - } +macros::from_impl!(IpCheckConfig); +impl From<&IpCheckConfigDto> for IpCheckConfig { + fn from(dto: &IpCheckConfigDto) -> Self { + Self { + url: dto.url.clone(), + url_ipv4: dto.url_ipv4.clone(), + url_ipv6: dto.url_ipv6.clone(), + pattern_ipv4: dto.pattern_ipv4.as_ref().and_then(|s| Regex::new(s).ok()), + pattern_ipv6: dto.pattern_ipv6.as_ref().and_then(|s| Regex::new(s).ok()), } - if let Some(url) = &self.url_ipv4 { - let url = url.trim(); - if url.is_empty() { - self.url_ipv4 = None; - } else { - self.url_ipv4 = Some(url.to_owned()); - } - } - if let Some(url) = &self.url_ipv6 { - let url = url.trim(); - if url.is_empty() { - self.url_ipv6 = None; - } else { - self.url_ipv6 = Some(url.to_owned()); - } - } - - if self.url.is_none() && self.url_ipv4.is_none() && self.url_ipv6.is_none() { - return Err(TuliproxError::new(TuliproxErrorKind::Info, "No url provided!".to_owned())); - } - - // TODO allow or do not allow ? - // if self.url.is_some() && (self.url_ipv4.is_some() || self.url_ipv6.is_some()) { - // return Err(TuliproxError::new(TuliproxErrorKind::Info, "url in combination with ipv4 and/or ipv6 url not allowed!".to_owned())); - // } - - if let Some(p4) = &self.pattern_ipv4 { - self.t_pattern_ipv4 = Some( - Regex::new(p4).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, format!("Invalid IPv4 regex: {p4} {err}")))?, - ); - } - if let Some(p6) = &self.pattern_ipv6 { - self.t_pattern_ipv6 = Some( - Regex::new(p6).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, format!("Invalid IPv6 regex: {p6} {err}")))?, - ); - } - Ok(()) } } +impl From<&IpCheckConfig> for IpCheckConfigDto { + fn from(dto: &IpCheckConfig) -> Self { + Self { + url: dto.url.clone(), + url_ipv4: dto.url_ipv4.clone(), + url_ipv6: dto.url_ipv6.clone(), + pattern_ipv4: dto.pattern_ipv4.as_ref().map(std::string::ToString::to_string), + pattern_ipv6: dto.pattern_ipv6.as_ref().map(std::string::ToString::to_string), + } + } +} \ No newline at end of file diff --git a/backend/src/model/config/log.rs b/backend/src/model/config/log.rs index 556fe6bc2..e5dc0a4e8 100644 --- a/backend/src/model/config/log.rs +++ b/backend/src/model/config/log.rs @@ -1,5 +1,11 @@ - +use shared::model::LogConfigDto; use shared::utils::default_as_true; +use crate::model::macros; +// We need serde for these structs to read them during +// start from the yaml file without reading the whole config. +// + + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] #[serde(deny_unknown_fields)] @@ -17,3 +23,23 @@ pub struct LogLevelConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub log: Option, } + +macros::from_impl!(LogConfig); +impl From<&LogConfigDto> for LogConfig { + fn from(dto: &LogConfigDto) -> Self { + Self { + sanitize_sensitive_info: dto.sanitize_sensitive_info, + log_active_user: dto.log_active_user, + log_level: dto.log_level.clone(), + } + } +} +impl From<&LogConfig> for LogConfigDto { + fn from(instance: &LogConfig) -> Self { + Self { + sanitize_sensitive_info: instance.sanitize_sensitive_info, + log_active_user: instance.log_active_user, + log_level: instance.log_level.clone(), + } + } +} \ No newline at end of file diff --git a/backend/src/model/config/macros.rs b/backend/src/model/config/macros.rs new file mode 100644 index 000000000..f28c60c92 --- /dev/null +++ b/backend/src/model/config/macros.rs @@ -0,0 +1,14 @@ +#[macro_export] +macro_rules! from_impl { + ($struct_name:ident) => { + paste::paste! { + impl From<[<$struct_name Dto>]> for $struct_name { + fn from(dto: [<$struct_name Dto>]) -> Self { + $struct_name::from(&dto) + } + } + } + } +} + +pub use from_impl; \ No newline at end of file diff --git a/backend/src/model/config/messaging.rs b/backend/src/model/config/messaging.rs index c9e76b99e..9dbff561b 100644 --- a/backend/src/model/config/messaging.rs +++ b/backend/src/model/config/messaging.rs @@ -1,36 +1,108 @@ -use shared::model::MsgKind; +use shared::model::{MessagingConfigDto, MsgKind, PushoverMessagingConfigDto, RestMessagingConfigDto, TelegramMessagingConfigDto}; +use crate::model::macros; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct TelegramMessagingConfig { pub bot_token: String, pub chat_ids: Vec, } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] +macros::from_impl!(TelegramMessagingConfig); +impl From<&TelegramMessagingConfigDto> for TelegramMessagingConfig { + fn from(dto: &TelegramMessagingConfigDto) -> Self { + Self { + bot_token: dto.bot_token.to_string(), + chat_ids: dto.chat_ids.clone(), + } + } +} + +impl From<&TelegramMessagingConfig> for TelegramMessagingConfigDto { + fn from(instance: &TelegramMessagingConfig) -> Self { + Self { + bot_token: instance.bot_token.to_string(), + chat_ids: instance.chat_ids.clone(), + } + } +} + +#[derive(Debug, Clone)] pub struct RestMessagingConfig { pub url: String, } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PushoverMessagingConfig { - pub(crate) url: Option, - pub(crate) token: String, - pub(crate) user: String, +macros::from_impl!(RestMessagingConfig); +impl From<&RestMessagingConfigDto> for RestMessagingConfig { + fn from(dto: &RestMessagingConfigDto) -> Self { + Self { + url: dto.url.to_string(), + } + } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] -pub struct MessagingConfig { - #[serde(default)] - pub notify_on: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub telegram: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rest: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pushover: Option, +impl From<&RestMessagingConfig> for RestMessagingConfigDto { + fn from(instance: &RestMessagingConfig) -> Self { + Self { + url: instance.url.to_string(), + } + } +} +#[derive(Debug, Clone)] +pub struct PushoverMessagingConfig { + pub url: String, + pub token: String, + pub user: String, +} + +macros::from_impl!(PushoverMessagingConfig); +impl From<&PushoverMessagingConfigDto> for PushoverMessagingConfig { + fn from(dto: &PushoverMessagingConfigDto) -> Self { + Self { + url: dto.url.as_ref().map_or_else(|| String::from("https://api.pushover.net/1/messages.json"), ToString::to_string), + token: dto.token.to_string(), + user: dto.user.to_string(), + } + } +} + +impl From<&PushoverMessagingConfig> for PushoverMessagingConfigDto { + fn from(instance: &PushoverMessagingConfig) -> Self { + Self { + url: Some(instance.url.to_string()), + token: instance.token.to_string(), + user: instance.user.to_string(), + } + } +} + +#[derive(Debug, Clone)] +pub struct MessagingConfig { + pub notify_on: Vec, + pub telegram: Option, + pub rest: Option, + pub pushover: Option, +} + +macros::from_impl!(MessagingConfig); +impl From<&MessagingConfigDto> for MessagingConfig { + fn from(dto: &MessagingConfigDto) -> Self { + Self { + notify_on: dto.notify_on.clone(), + telegram: dto.telegram.as_ref().map(Into::into), + rest: dto.rest.as_ref().map(Into::into), + pushover: dto.pushover.as_ref().map(Into::into), + } + } +} + +impl From<&MessagingConfig> for MessagingConfigDto { + fn from(instance: &MessagingConfig) -> Self { + Self { + notify_on: instance.notify_on.clone(), + telegram: instance.telegram.as_ref().map(Into::into), + rest: instance.rest.as_ref().map(Into::into), + pushover: instance.pushover.as_ref().map(Into::into), + } + } } \ No newline at end of file diff --git a/backend/src/model/config/mod.rs b/backend/src/model/config/mod.rs index 27bda7f6d..0ae7a6325 100644 --- a/backend/src/model/config/mod.rs +++ b/backend/src/model/config/mod.rs @@ -1,6 +1,6 @@ mod trakt; mod base; -mod webui; +mod web_ui; mod web_auth; mod messaging; mod hdhomerun; @@ -13,6 +13,7 @@ mod log; mod input; mod stream; mod epg; +mod epg_smart_match; mod reverse_proxy; mod cache; mod rate_limit; @@ -20,28 +21,37 @@ mod proxy; mod schedule; mod api_proxy; mod rename; - mod healthcheck; +mod app; +mod api; +mod api_user; +mod trakt_api; +pub mod macros; -pub use base::*; +pub use api::*; pub use api_proxy::*; -pub use webui::*; -pub use web_auth::*; -pub use messaging::*; -pub use hdhomerun::*; -pub use ip_check::*; -pub use source::*; -pub use target::*; -pub use sort::*; -pub use video_download::*; -pub use schedule::*; -pub use log::*; -pub use input::*; -pub use stream::*; +pub use api_user::*; +pub use app::*; +pub use base::*; pub use epg::*; -pub use rate_limit::*; -pub use reverse_proxy::*; -pub use proxy::*; -pub use rename::*; -pub use trakt::*; +pub use epg_smart_match::*; +pub use hdhomerun::*; pub use healthcheck::*; +pub use input::*; +pub use ip_check::*; +pub use log::*; +pub use messaging::*; +pub use proxy::*; +pub use rate_limit::*; +pub use rename::*; +pub use reverse_proxy::*; +pub use schedule::*; +pub use sort::*; +pub use source::*; +pub use stream::*; +pub use target::*; +pub use trakt::*; +pub use trakt_api::*; +pub use video_download::*; +pub use web_auth::*; +pub use web_ui::*; diff --git a/backend/src/model/config/proxy.rs b/backend/src/model/config/proxy.rs index 3a9fc014e..9a24f6d88 100644 --- a/backend/src/model/config/proxy.rs +++ b/backend/src/model/config/proxy.rs @@ -1,33 +1,30 @@ -use shared::error::{TuliproxError, TuliproxErrorKind}; +use shared::model::ProxyConfigDto; +use crate::model::macros; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct ProxyConfig { pub url: String, pub username: Option, pub password: Option, } -impl ProxyConfig { - pub(crate) fn prepare(&mut self) -> Result<(), TuliproxError> { - if self.username.is_some() || self.password.is_some() { - if let (Some(username), Some(password)) = (self.username.as_ref(), self.password.as_ref()) { - let uname = username.trim(); - let pwd = password.trim(); - if uname.is_empty() || pwd.is_empty() { - return Err(TuliproxError::new(TuliproxErrorKind::Info, "Proxy credentials missing".to_string())); - } - self.username = Some(uname.to_string()); - self.password = Some(pwd.to_string()); - } else { - return Err(TuliproxError::new(TuliproxErrorKind::Info, "Proxy credentials missing".to_string())); - } +macros::from_impl!(ProxyConfig); +impl From<&ProxyConfigDto> for ProxyConfig { + fn from(dto: &ProxyConfigDto) -> Self { + Self { + url: dto.url.to_string(), + username: dto.username.clone(), + password: dto.password.clone(), } - - self.url = self.url.trim().to_string(); - if self.url.is_empty() { - return Err(TuliproxError::new(TuliproxErrorKind::Info, "Proxy url missing".to_string())); - } - Ok(()) } -} \ No newline at end of file +} + +impl From<&ProxyConfig> for ProxyConfigDto { + fn from(dto: &ProxyConfig) -> Self { + Self { + url: dto.url.to_string(), + username: dto.username.clone(), + password: dto.password.clone(), + } + } +} diff --git a/backend/src/model/config/rate_limit.rs b/backend/src/model/config/rate_limit.rs index c123239b5..5021f3ee5 100644 --- a/backend/src/model/config/rate_limit.rs +++ b/backend/src/model/config/rate_limit.rs @@ -1,21 +1,30 @@ -use shared::error::{TuliproxError, TuliproxErrorKind}; +use shared::model::RateLimitConfigDto; +use crate::model::macros; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct RateLimitConfig { pub enabled: bool, pub period_millis: u64, pub burst_size: u32, } -impl RateLimitConfig { - pub(crate) fn prepare(&self) -> Result<(), TuliproxError> { - if self.period_millis == 0 { - return Err(TuliproxError::new(TuliproxErrorKind::Info, "Rate limiter period can't be 0".to_string())); +macros::from_impl!(RateLimitConfig); +impl From<&RateLimitConfigDto> for RateLimitConfig { + fn from(dto: &RateLimitConfigDto) -> Self { + Self { + enabled: dto.enabled, + period_millis: dto.period_millis, + burst_size: dto.burst_size, } - if self.burst_size == 0 { - return Err(TuliproxError::new(TuliproxErrorKind::Info, "Rate limiter burst can't be 0".to_string())); + } +} + +impl From<&RateLimitConfig> for RateLimitConfigDto { + fn from(instance: &RateLimitConfig) -> Self { + Self { + enabled: instance.enabled, + period_millis: instance.period_millis, + burst_size: instance.burst_size, } - Ok(()) } } \ No newline at end of file diff --git a/backend/src/model/config/rename.rs b/backend/src/model/config/rename.rs index 04df7e13e..32990eac7 100644 --- a/backend/src/model/config/rename.rs +++ b/backend/src/model/config/rename.rs @@ -1,26 +1,30 @@ -use shared::model::ItemField; -use crate::foundation::filter::{apply_templates_to_pattern_single, PatternTemplate}; -use shared::error::{TuliproxError, TuliproxErrorKind, create_tuliprox_error_result}; +use shared::model::{ConfigRenameDto, ItemField}; +use crate::model::macros; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct ConfigRename { pub field: ItemField, - pub pattern: String, pub new_name: String, - #[serde(skip_serializing, skip_deserializing)] - pub re: Option, + pub pattern: regex::Regex, } -impl ConfigRename { - pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { - self.pattern = apply_templates_to_pattern_single(&self.pattern, templates)?; - match regex::Regex::new(&self.pattern) { - Ok(pattern) => { - self.re = Some(pattern); - Ok(()) - } - Err(err) => create_tuliprox_error_result!(TuliproxErrorKind::Info, "cant parse regex: {} {err}", &self.pattern), +macros::from_impl!(ConfigRename); +impl From<&ConfigRenameDto> for ConfigRename { + fn from(dto: &ConfigRenameDto) -> Self { + Self { + field: dto.field, + new_name: dto.new_name.to_string(), + pattern: regex::Regex::new(&dto.pattern).unwrap() + } + } +} + +impl From<&ConfigRename> for ConfigRenameDto { + fn from(instance: &ConfigRename) -> Self { + Self { + field: instance.field, + new_name: instance.new_name.to_string(), + pattern: instance.pattern.to_string() } } } \ No newline at end of file diff --git a/backend/src/model/config/reverse_proxy.rs b/backend/src/model/config/reverse_proxy.rs index dc531856c..c7df054fd 100644 --- a/backend/src/model/config/reverse_proxy.rs +++ b/backend/src/model/config/reverse_proxy.rs @@ -1,42 +1,38 @@ -use log::warn; -use shared::error::TuliproxError; +use shared::model::ReverseProxyConfigDto; use crate::model::config::cache::CacheConfig; -use crate::model::{RateLimitConfig, StreamConfig}; +use crate::model::{macros, RateLimitConfig, StreamConfig}; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct ReverseProxyConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub stream: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cache: Option, - #[serde(default)] pub resource_rewrite_disabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rate_limit: Option, - #[serde(default)] pub disable_referer_header: bool, + pub stream: Option, + pub cache: Option, + pub rate_limit: Option, } +macros::from_impl!(ReverseProxyConfig); -impl ReverseProxyConfig { - pub(crate) fn prepare(&mut self, working_dir: &str) -> Result<(), TuliproxError> { - if let Some(stream) = self.stream.as_mut() { - stream.prepare()?; +impl From<&ReverseProxyConfigDto> for ReverseProxyConfig { + fn from(dto: &ReverseProxyConfigDto) -> Self { + Self { + resource_rewrite_disabled: dto.resource_rewrite_disabled, + disable_referer_header: dto.disable_referer_header, + stream: dto.stream.as_ref().map(Into::into), + cache: dto.cache.as_ref().map(Into::into), + rate_limit: dto.rate_limit.as_ref().map(Into::into), + } + } +} + +impl From<&ReverseProxyConfig> for ReverseProxyConfigDto { + fn from(instance: &ReverseProxyConfig) -> Self { + Self { + resource_rewrite_disabled: instance.resource_rewrite_disabled, + disable_referer_header: instance.disable_referer_header, + stream: instance.stream.as_ref().map(Into::into), + cache: instance.cache.as_ref().map(Into::into), + rate_limit: instance.rate_limit.as_ref().map(Into::into), } - if let Some(cache) = self.cache.as_mut() { - if cache.enabled && self.resource_rewrite_disabled { - warn!("The cache is disabled because resource rewrite is disabled"); - cache.enabled = false; - } - cache.prepare(working_dir)?; - } - - if let Some(rate_limit) = self.rate_limit.as_mut() { - if rate_limit.enabled { - rate_limit.prepare()?; - } - } - Ok(()) } } diff --git a/backend/src/model/config/schedule.rs b/backend/src/model/config/schedule.rs index aea8d6841..85a9d2a66 100644 --- a/backend/src/model/config/schedule.rs +++ b/backend/src/model/config/schedule.rs @@ -1,9 +1,26 @@ +use shared::model::ScheduleConfigDto; +use crate::model::macros; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct ScheduleConfig { - #[serde(default)] pub schedule: String, - #[serde(default, skip_serializing_if = "Option::is_none")] pub targets: Option>, } + +macros::from_impl!(ScheduleConfig); +impl From<&ScheduleConfigDto> for ScheduleConfig { + fn from(dto: &ScheduleConfigDto) -> Self { + Self { + schedule: dto.schedule.to_string(), + targets: dto.targets.clone(), + } + } +} +impl From<&ScheduleConfig> for ScheduleConfigDto { + fn from(dto: &ScheduleConfig) -> Self { + Self { + schedule: dto.schedule.to_string(), + targets: dto.targets.clone(), + } + } +} diff --git a/backend/src/model/config/sort.rs b/backend/src/model/config/sort.rs index 0826aa4cf..4b7d8b778 100644 --- a/backend/src/model/config/sort.rs +++ b/backend/src/model/config/sort.rs @@ -1,129 +1,89 @@ use regex::Regex; -use crate::foundation::filter::{apply_templates_to_pattern, apply_templates_to_pattern_single, PatternTemplate, TemplateValue}; -use shared::error::{TuliproxError, TuliproxErrorKind, create_tuliprox_error, handle_tuliprox_error_result_list}; -use shared::model::ItemField; +use shared::model::{ConfigSortChannelDto, ConfigSortDto, ConfigSortGroupDto, ItemField, SortOrder}; +use crate::model::macros; -#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize)] -pub enum SortOrder { - #[serde(rename = "asc")] - Asc, - #[serde(rename = "desc")] - Desc, -} - -fn compile_regex_vec(patterns: Option<&Vec>) -> Result>, TuliproxError> { - patterns.as_ref() - .map(|seq| { - seq.iter() - .map(|s| Regex::new(s).map_err(|err| { - create_tuliprox_error!(TuliproxErrorKind::Info, "cant parse regex: {s} {err}") - })) - .collect::, _>>() - }) - .transpose() // convert Option> to Result> -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct ConfigSortGroup { pub order: SortOrder, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sequence: Option>, - #[serde(default, skip)] - pub t_re_sequence: Option>, + pub sequence: Option>, } - -impl ConfigSortGroup { - - pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { - let processed_sequence = match (&self.sequence, templates) { - (Some(seqs), Some(_templs)) => { - let mut result = Vec::new(); - for s in seqs { - match apply_templates_to_pattern(s, templates, true)? { - TemplateValue::Single(val) => result.push(val), - TemplateValue::Multi(vals) => result.extend(vals), - } - } - Some(result) - }, - (Some(seqs), None) => Some(seqs.clone()), - (None, _) => None, - }; - - self.t_re_sequence = compile_regex_vec(processed_sequence.as_ref())?; - Ok(()) +macros::from_impl!(ConfigSortGroup); +impl From<&ConfigSortGroupDto> for ConfigSortGroup { + fn from(dto: &ConfigSortGroupDto) -> Self { + Self { + order: dto.order, + sequence: dto.t_sequence.clone(), + } } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] +impl From<&ConfigSortGroup> for ConfigSortGroupDto { + fn from(instance: &ConfigSortGroup) -> Self { + Self { + order: instance.order, + sequence: instance.sequence.as_ref().map(|l| l.iter().map(ToString::to_string).collect()), + t_sequence: None, + } + } +} + +#[derive(Debug, Clone)] pub struct ConfigSortChannel { - // channel field pub field: ItemField, - // match against group title - pub group_pattern: String, + pub group_pattern: Regex, pub order: SortOrder, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sequence: Option>, - #[serde(default, skip)] - pub t_re_sequence: Option>, - #[serde(skip)] - pub t_re_group_pattern: Option, + pub sequence: Option>, } -impl ConfigSortChannel { - pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { - self.group_pattern = apply_templates_to_pattern_single(&self.group_pattern, templates)?; - // Compile group_pattern - self.t_re_group_pattern = Some( - Regex::new(&self.group_pattern).map_err(|err| { - create_tuliprox_error!(TuliproxErrorKind::Info, "cant parse regex: {} {err}", &self.group_pattern) - })? - ); - - // Transform sequence with templates if provided, otherwise use raw sequence - let processed_sequence = match (&self.sequence, templates) { - (Some(seqs), Some(tmpls)) => { - let mut result = Vec::new(); - for s in seqs { - match apply_templates_to_pattern(s, Some(tmpls), true)? { - TemplateValue::Single(val) => result.push(val), - TemplateValue::Multi(vals) => result.extend(vals), - } - } - Some(result) - }, - (Some(seqs), None) => Some(seqs.clone()), - (None, _) => None, - }; - - // Compile regex patterns - self.t_re_sequence = compile_regex_vec(processed_sequence.as_ref())?; - Ok(()) +macros::from_impl!(ConfigSortChannel); +impl From<&ConfigSortChannelDto> for ConfigSortChannel { + fn from(dto: &ConfigSortChannelDto) -> Self { + Self { + field: dto.field, + group_pattern: Regex::new(&dto.group_pattern).unwrap(), + order: dto.order, + sequence: dto.t_sequence.clone(), + } } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +impl From<&ConfigSortChannel> for ConfigSortChannelDto { + fn from(instance: &ConfigSortChannel) -> Self { + Self { + field: instance.field, + group_pattern: instance.group_pattern.to_string(), + order: instance.order, + sequence: instance.sequence.as_ref().map(|l| l.iter().map(ToString::to_string).collect()), + t_sequence: None, + } + } +} + +#[derive(Debug, Clone, Default)] pub struct ConfigSort { - #[serde(default)] pub match_as_ascii: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] pub groups: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub channels: Option>, } -impl ConfigSort { - pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { - if let Some(group) = self.groups.as_mut() { - group.prepare(templates)?; +macros::from_impl!(ConfigSort); +impl From<&ConfigSortDto> for ConfigSort { + fn from(dto: &ConfigSortDto) -> Self { + Self { + match_as_ascii: dto.match_as_ascii, + groups: dto.groups.as_ref().map(Into::into), + channels: dto.channels.as_ref().map(|v| v.iter().map(Into::into).collect()), } - if let Some(channels) = self.channels.as_mut() { - handle_tuliprox_error_result_list!(TuliproxErrorKind::Info, channels.iter_mut().map(|csc| csc.prepare(templates))); - } - Ok(()) } -} \ No newline at end of file +} + +impl From<&ConfigSort> for ConfigSortDto { + fn from(instance: &ConfigSort) -> Self { + Self { + match_as_ascii: instance.match_as_ascii, + groups: instance.groups.as_ref().map(Into::into), + channels: instance.channels.as_ref().map(|v| v.iter().map(Into::into).collect()), + } + } +} diff --git a/backend/src/model/config/source.rs b/backend/src/model/config/source.rs index 5559eddd2..1d2e905c5 100644 --- a/backend/src/model/config/source.rs +++ b/backend/src/model/config/source.rs @@ -1,28 +1,21 @@ +use std::borrow::Cow; use std::collections::{HashMap, HashSet}; -use crate::foundation::filter::{prepare_templates, PatternTemplate}; -use shared::error::{TuliproxError, TuliproxErrorKind, handle_tuliprox_error_result_list, create_tuliprox_error_result}; -use crate::model::{ConfigInput, ConfigTarget, ProcessTargets}; -use shared::utils::default_as_default; +use std::sync::Arc; +use shared::error::{TuliproxError, TuliproxErrorKind, create_tuliprox_error_result}; +use shared::model::{ConfigSourceDto, PatternTemplate, SourcesConfigDto}; +use crate::model::{macros, ConfigInput, ConfigTarget, ProcessTargets}; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct ConfigSource { - pub inputs: Vec, - pub targets: Vec, + pub inputs: Vec>, + pub targets: Vec>, } impl ConfigSource { - #[allow(clippy::cast_possible_truncation)] - pub fn prepare(&mut self, index: u16, include_computed: bool) -> Result { - handle_tuliprox_error_result_list!(TuliproxErrorKind::Info, self.inputs.iter_mut().enumerate() - .map(|(idx, i)| i.prepare(index+(idx as u16), include_computed))); - Ok(index + (self.inputs.len() as u16)) - } - - pub fn get_inputs_for_target(&self, target_name: &str) -> Option> { + pub fn get_inputs_for_target(&self, target_name: &str) -> Option>> { for target in &self.targets { if target.name.eq(target_name) { - let inputs = self.inputs.iter().filter(|&i| i.enabled).collect::>(); + let inputs = self.inputs.iter().filter(|&i| i.enabled).map(Arc::clone).collect::>>(); if !inputs.is_empty() { return Some(inputs); } @@ -32,89 +25,48 @@ impl ConfigSource { } } -#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] +macros::from_impl!(ConfigSource); +impl From<&ConfigSourceDto> for ConfigSource { + fn from(dto: &ConfigSourceDto) -> Self { + Self { + inputs: dto.inputs.iter().map(|c| Arc::new(ConfigInput::from(c))).collect(), + targets: dto.targets.iter().map(|c| Arc::new(ConfigTarget::from(c))).collect(), + } + } +} + +#[derive(Default, Debug, Clone)] pub struct SourcesConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] pub templates: Option>, pub sources: Vec, } +impl From for SourcesConfig { + fn from(dto: SourcesConfigDto) -> Self { + Self { + templates: dto.templates.clone(), + sources: dto.sources.iter().map(Into::into).collect(), + } + } +} + impl SourcesConfig { pub(crate) fn get_source_at(&self, idx: usize) -> Option<&ConfigSource> { self.sources.get(idx) } - pub fn prepare(&mut self, include_computed: bool) -> Result<(), TuliproxError> { - self.prepare_templates()?; - self.prepare_sources(include_computed)?; - Ok(()) - } - - fn prepare_sources(&mut self, include_computed: bool) -> Result<(), TuliproxError> { - // prepare sources and set id's - let mut source_index: u16 = 1; - let mut target_index: u16 = 1; - for source in &mut self.sources { - source_index = source.prepare(source_index, include_computed)?; - for target in &mut source.targets { - // prepare target templates - let prepare_result = match &self.templates { - Some(templ) => target.prepare(target_index, Some(templ)), - _ => target.prepare(target_index, None) - }; - prepare_result?; - target_index += 1; - } - } - Ok(()) - } - - fn prepare_templates(&mut self) -> Result<(), TuliproxError> { - if let Some(templates) = &mut self.templates { - match prepare_templates(templates) { - Ok(tmplts) => { - self.templates = Some(tmplts); - } - Err(err) => { - return Err(err); - } - } - } - Ok(()) - } - - pub fn check_unique_target_names(&mut self) -> Result, TuliproxError> { - let mut seen_names = HashSet::new(); - let default_target_name = default_as_default(); - for source in &self.sources { - for target in &source.targets { - // check the target name is unique - let target_name = target.name.trim().to_string(); - if target_name.is_empty() { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "target name required"); - } - if !default_target_name.eq_ignore_ascii_case(target_name.as_str()) { - if seen_names.contains(target_name.as_str()) { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "target names should be unique: {}", target_name); - } - seen_names.insert(target_name); - } - } - } - Ok(seen_names) - } - pub fn get_target_by_id(&self, target_id: u16) -> Option<&ConfigTarget> { + pub fn get_target_by_id(&self, target_id: u16) -> Option> { for source in &self.sources { for target in &source.targets { if target.id == target_id { - return Some(target); + return Some(Arc::clone(target)); } } } None } + /// Returns the targets that were specified as parameters. /// If invalid targets are found, the program will be terminated. /// The return value has `enabled` set to true, if selective targets should be processed, otherwise false. @@ -163,4 +115,16 @@ impl SourcesConfig { targets, }) } -} \ No newline at end of file + + pub fn get_unique_target_names(&self) -> HashSet> { + let mut seen_names = HashSet::new(); + for source in &self.sources { + for target in &source.targets { + // check the target name is unique + let target_name = Cow::Borrowed(target.name.as_str()); + seen_names.insert(target_name); + } + } + seen_names + } +} diff --git a/backend/src/model/config/stream.rs b/backend/src/model/config/stream.rs index 11bb86054..ef1536dc9 100644 --- a/backend/src/model/config/stream.rs +++ b/backend/src/model/config/stream.rs @@ -1,80 +1,77 @@ -use shared::utils::default_grace_period_millis; -use shared::utils::default_grace_period_timeout_secs; -use shared::error::{TuliproxError, TuliproxErrorKind}; -use shared::info_err; +use shared::model::{StreamBufferConfigDto, StreamConfigDto}; use shared::utils::parse_to_kbps; use crate::api::model::streams::transport_stream_buffer::TransportStreamBuffer; +use crate::model::macros; -const STREAM_QUEUE_SIZE: usize = 1024; // mpsc channel holding messages. with 8192byte chunks and 2Mbit/s approx 8MB - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct StreamBufferConfig { - #[serde(default)] pub enabled: bool, - #[serde(default)] pub size: usize, } -impl StreamBufferConfig { - fn prepare(&mut self) { - if self.enabled && self.size == 0 { - self.size = STREAM_QUEUE_SIZE; +macros::from_impl!(StreamBufferConfig); +impl From<&StreamBufferConfigDto> for StreamBufferConfig { + fn from(dto: &StreamBufferConfigDto) -> Self { + Self { + enabled: dto.enabled, + size: dto.size, } } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +impl From<&StreamBufferConfig> for StreamBufferConfigDto { + fn from(dto: &StreamBufferConfig) -> Self { + Self { + enabled: dto.enabled, + size: dto.size, + } + } +} + +#[derive(Debug, Clone)] pub struct StreamConfig { - #[serde(default)] pub retry: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] pub buffer: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub throttle: Option, - #[serde(default = "default_grace_period_millis")] pub grace_period_millis: u64, - #[serde(default = "default_grace_period_timeout_secs")] pub grace_period_timeout_secs: u64, - #[serde(default)] pub forced_retry_interval_secs: u32, - #[serde(default, skip)] + pub throttle_str: Option, pub throttle_kbps: u64, } -impl StreamConfig { - pub(crate) fn prepare(&mut self) -> Result<(), TuliproxError> { - if let Some(buffer) = self.buffer.as_mut() { - buffer.prepare(); +macros::from_impl!(StreamConfig); +impl From<&StreamConfigDto> for StreamConfig { + fn from(dto: &StreamConfigDto) -> Self { + Self { + retry: dto.retry, + buffer: dto.buffer.as_ref().map(Into::into), + grace_period_millis: dto.grace_period_millis, + grace_period_timeout_secs: dto.grace_period_timeout_secs, + forced_retry_interval_secs: dto.forced_retry_interval_secs, + throttle_str: dto.throttle.clone(), + throttle_kbps: dto.throttle.as_ref().map_or(0u64, |throttle| parse_to_kbps(throttle).unwrap_or(0u64)), } - if let Some(throttle) = &self.throttle { - self.throttle_kbps = parse_to_kbps(throttle).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, err))?; - } - - if self.grace_period_millis > 0 { - if self.grace_period_timeout_secs == 0 { - let triple_ms = self.grace_period_millis.saturating_mul(3); - self.grace_period_timeout_secs = std::cmp::max(1, triple_ms.div_ceil(1000)); - } else if self.grace_period_millis / 1000 > self.grace_period_timeout_secs { - return Err(info_err!(format!("Grace time period timeout {} sec should be more than grace time period {} ms", self.grace_period_timeout_secs, self.grace_period_millis))); - } - } - - Ok(()) } } +impl From<&StreamConfig> for StreamConfigDto { + fn from(instance: &StreamConfig) -> Self { + Self { + retry: instance.retry, + buffer: instance.buffer.as_ref().map(Into::into), + grace_period_millis: instance.grace_period_millis, + grace_period_timeout_secs: instance.grace_period_timeout_secs, + forced_retry_interval_secs: instance.forced_retry_interval_secs, + throttle: instance.throttle_str.clone(), + throttle_kbps: instance.throttle_kbps, + } + } +} -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct CustomStreamResponse { - #[serde(default, skip)] pub channel_unavailable: Option, - #[serde(default, skip)] pub user_connections_exhausted: Option, // user has no more connections - #[serde(default, skip)] pub provider_connections_exhausted: Option, // provider limit reached, has no more connections - #[serde(default, skip)] pub user_account_expired: Option, } \ No newline at end of file diff --git a/backend/src/model/config/target.rs b/backend/src/model/config/target.rs index 2c54ab6e7..63c152d75 100644 --- a/backend/src/model/config/target.rs +++ b/backend/src/model/config/target.rs @@ -1,13 +1,13 @@ -use crate::foundation::filter::{get_filter, Filter, PatternTemplate, ValueProvider}; use crate::model::mapping::Mapping; use crate::model::config::trakt::TraktConfig; -use shared::error::{create_tuliprox_error_result, handle_tuliprox_error_result_list, info_err, TuliproxError, TuliproxErrorKind}; -use shared::utils::{default_as_default, default_as_true, default_resolve_delay_secs}; use arc_swap::ArcSwapOption; -use shared::model::{ClusterFlags, ProcessingOrder, StrmExportStyle, TargetType}; +use shared::model::{ConfigTargetDto, ConfigTargetOptions, HdHomeRunTargetOutputDto, M3uTargetOutputDto, ProcessingOrder, StrmExportStyle, StrmTargetOutputDto, TargetOutputDto, TargetType, TraktConfigDto, XtreamTargetOutputDto}; use shared::model::PlaylistItemType; use std::sync::Arc; -use crate::model::{ConfigRename, ConfigSort}; +use regex::Regex; +use shared::foundation::filter::Filter; +use shared::foundation::filter::ValueProvider; +use crate::model::{macros, ConfigRename, ConfigSort}; #[derive(Clone, Debug)] @@ -27,88 +27,146 @@ impl ProcessTargets { } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] -pub struct ConfigTargetOptions { - #[serde(default)] - pub ignore_logo: bool, - #[serde(default)] - pub share_live_streams: bool, - #[serde(default)] - pub remove_duplicates: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub force_redirect: Option, -} - #[allow(clippy::struct_excessive_bools)] -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct XtreamTargetOutput { - #[serde(default = "default_as_true")] pub skip_live_direct_source: bool, - #[serde(default = "default_as_true")] pub skip_video_direct_source: bool, - #[serde(default = "default_as_true")] pub skip_series_direct_source: bool, - #[serde(default)] pub resolve_series: bool, - #[serde(default = "default_resolve_delay_secs")] pub resolve_series_delay: u16, - #[serde(default)] pub resolve_vod: bool, - #[serde(default = "default_resolve_delay_secs")] pub resolve_vod_delay: u16, - #[serde(default, skip_serializing_if = "Option::is_none")] pub trakt: Option, } -impl XtreamTargetOutput { - pub fn prepare(&mut self) { - if let Some(trakt) = &mut self.trakt { - trakt.prepare(); +macros::from_impl!(XtreamTargetOutput); +impl From<&XtreamTargetOutputDto> for XtreamTargetOutput { + fn from(dto: &XtreamTargetOutputDto) -> Self { + Self { + skip_live_direct_source: dto.skip_live_direct_source, + skip_video_direct_source: dto.skip_video_direct_source, + skip_series_direct_source: dto.skip_series_direct_source, + resolve_series: dto.resolve_series, + resolve_series_delay: dto.resolve_series_delay, + resolve_vod: dto.resolve_vod, + resolve_vod_delay: dto.resolve_vod_delay, + trakt: dto.trakt.as_ref().map(Into::into), } } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] +impl From<&XtreamTargetOutput> for XtreamTargetOutputDto { + fn from(instance: &XtreamTargetOutput) -> Self { + Self { + skip_live_direct_source: instance.skip_live_direct_source, + skip_video_direct_source: instance.skip_video_direct_source, + skip_series_direct_source: instance.skip_series_direct_source, + resolve_series: instance.resolve_series, + resolve_series_delay: instance.resolve_series_delay, + resolve_vod: instance.resolve_vod, + resolve_vod_delay: instance.resolve_vod_delay, + trakt: instance.trakt.as_ref().map(TraktConfigDto::from), + } + } +} + + +#[derive(Debug, Clone)] pub struct M3uTargetOutput { - #[serde(skip_serializing_if = "Option::is_none")] pub filename: Option, - #[serde(default)] pub include_type_in_url: bool, - #[serde(default)] pub mask_redirect_url: bool, } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] +macros::from_impl!(M3uTargetOutput); +impl From<&M3uTargetOutputDto> for M3uTargetOutput { + fn from(dto: &M3uTargetOutputDto) -> Self { + Self { + filename: dto.filename.clone(), + include_type_in_url: dto.include_type_in_url, + mask_redirect_url: dto.mask_redirect_url, + } + } +} +impl From<&M3uTargetOutput> for M3uTargetOutputDto { + fn from(instance: &M3uTargetOutput) -> Self { + Self { + filename: instance.filename.clone(), + include_type_in_url: instance.include_type_in_url, + mask_redirect_url: instance.mask_redirect_url, + } + } +} + + +#[derive(Debug, Clone)] pub struct StrmTargetOutput { pub directory: String, - #[serde(default, skip_serializing_if = "Option::is_none")] pub username: Option, - #[serde(default)] pub style: StrmExportStyle, - #[serde(default)] pub flat: bool, - #[serde(default)] pub underscore_whitespace: bool, - #[serde(default)] pub cleanup: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] pub strm_props: Option>, } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] + +macros::from_impl!(StrmTargetOutput); +impl From<&StrmTargetOutputDto> for StrmTargetOutput { + fn from(dto: &StrmTargetOutputDto) -> Self { + Self { + directory: dto.directory.to_string(), + username: dto.username.clone(), + style: dto.style, + flat: dto.flat, + underscore_whitespace: dto.underscore_whitespace, + cleanup: dto.cleanup, + strm_props: dto.strm_props.clone(), + } + } +} +impl From<&StrmTargetOutput> for StrmTargetOutputDto { + fn from(instance: &StrmTargetOutput) -> Self { + Self { + directory: instance.directory.to_string(), + username: instance.username.clone(), + style: instance.style, + flat: instance.flat, + underscore_whitespace: instance.underscore_whitespace, + cleanup: instance.cleanup, + strm_props: instance.strm_props.clone(), + } + } +} + +#[derive(Debug, Clone)] pub struct HdHomeRunTargetOutput { pub device: String, pub username: String, - #[serde(default, skip_serializing_if = "Option::is_none")] pub use_output: Option, } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")] +macros::from_impl!(HdHomeRunTargetOutput); +impl From<&HdHomeRunTargetOutputDto> for HdHomeRunTargetOutput { + fn from(dto: &HdHomeRunTargetOutputDto) -> Self { + Self { + device: dto.device.to_string(), + username: dto.username.to_string(), + use_output: dto.use_output, + } + } +} +impl From<&HdHomeRunTargetOutput> for HdHomeRunTargetOutputDto { + fn from(instance: &HdHomeRunTargetOutput) -> Self { + Self { + device: instance.device.to_string(), + username: instance.username.to_string(), + use_output: instance.use_output, + } + } +} + +#[derive(Debug, Clone)] pub enum TargetOutput { Xtream(XtreamTargetOutput), M3u(M3uTargetOutput), @@ -116,177 +174,49 @@ pub enum TargetOutput { HdHomeRun(HdHomeRunTargetOutput), } -impl TargetOutput { - pub fn prepare(&mut self) { - match self { - TargetOutput::Xtream(output) => output.prepare(), - TargetOutput::M3u(_) - | TargetOutput::Strm(_) - | TargetOutput::HdHomeRun(_) => {} +macros::from_impl!(TargetOutput); +impl From<&TargetOutputDto> for TargetOutput { + fn from(dto: &TargetOutputDto) -> Self { + match dto { + TargetOutputDto::Xtream(o) => TargetOutput::Xtream(XtreamTargetOutput::from(o)), + TargetOutputDto::M3u(o) => TargetOutput::M3u(M3uTargetOutput::from(o)), + TargetOutputDto::Strm(o) => TargetOutput::Strm(StrmTargetOutput::from(o)), + TargetOutputDto::HdHomeRun(o) => TargetOutput::HdHomeRun(HdHomeRunTargetOutput::from(o)), } } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +impl From<&TargetOutput> for TargetOutputDto { + fn from(instance: &TargetOutput) -> Self { + match instance { + TargetOutput::Xtream(o) => TargetOutputDto::Xtream(XtreamTargetOutputDto::from(o)), + TargetOutput::M3u(o) => TargetOutputDto::M3u(M3uTargetOutputDto::from(o)), + TargetOutput::Strm(o) => TargetOutputDto::Strm(StrmTargetOutputDto::from(o)), + TargetOutput::HdHomeRun(o) => TargetOutputDto::HdHomeRun(HdHomeRunTargetOutputDto::from(o)), + } + } +} + +#[derive(Debug, Clone)] pub struct ConfigTarget { - #[serde(skip)] pub id: u16, - #[serde(default = "default_as_true")] pub enabled: bool, - #[serde(default = "default_as_default")] pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] pub options: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub sort: Option, - pub filter: String, - #[serde(default)] + pub filter: Filter, pub output: Vec, - #[serde(skip_serializing_if = "Option::is_none")] pub rename: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub mapping: Option>, - #[serde(default)] + pub mapping_ids: Option>, + pub mapping: Arc>>, pub processing_order: ProcessingOrder, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub watch: Option>, - #[serde(skip)] - pub t_watch_re: Option>, - #[serde(skip)] - pub t_filter: Option, - #[serde(skip)] - pub t_mapping: Arc>>, + pub watch: Option>, } impl ConfigTarget { - #[allow(clippy::too_many_lines)] - pub fn prepare(&mut self, id: u16, templates: Option<&Vec>) -> Result<(), TuliproxError> { - self.id = id; - if self.output.is_empty() { - return Err(info_err!(format!("Missing output format for {}", self.name))); - } - let mut m3u_cnt = 0; - let mut xtream_cnt = 0; - let mut strm_cnt = 0; - let mut strm_needs_xtream = false; - let mut hdhr_cnt = 0; - let mut hdhomerun_needs_m3u = false; - let mut hdhomerun_needs_xtream = false; - - let mut strm_export_styles = vec![]; - let mut strm_directories: Vec<&str> = vec![]; - - for target_output in &mut self.output { - target_output.prepare(); - match target_output { - TargetOutput::Xtream(_) => { - xtream_cnt += 1; - if default_as_default().eq_ignore_ascii_case(&self.name) { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "unique target name is required for xtream type output: {}", self.name); - } - } - TargetOutput::M3u(m3u_output) => { - m3u_cnt += 1; - m3u_output.filename = m3u_output.filename.as_ref().map(|s| s.trim().to_string()); - } - TargetOutput::Strm(strm_output) => { - strm_cnt += 1; - strm_output.directory = strm_output.directory.trim().to_string(); - if strm_output.directory.trim().is_empty() { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "directory is required for strm type: {}", self.name); - } - if let Some(username) = &mut strm_output.username { - *username = username.trim().to_string(); - } - let has_username = strm_output.username.as_ref().is_some_and(|u| !u.is_empty()); - - if has_username { - strm_needs_xtream = true; - } - if strm_export_styles.contains(&strm_output.style) { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "strm outputs with same export style are not allowed: {}", self.name); - } - strm_export_styles.push(strm_output.style); - if strm_directories.contains(&strm_output.directory.as_str()) { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "strm outputs with same export directory are not allowed: {}", self.name); - } - strm_directories.push(strm_output.directory.as_str()); - } - TargetOutput::HdHomeRun(hdhomerun_output) => { - hdhr_cnt += 1; - hdhomerun_output.username = hdhomerun_output.username.trim().to_string(); - if hdhomerun_output.username.is_empty() { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Username is required for HdHomeRun type: {}", self.name); - } - - hdhomerun_output.device = hdhomerun_output.device.trim().to_string(); - if hdhomerun_output.device.is_empty() { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Device is required for HdHomeRun type: {}", self.name); - } - - if let Some(use_output) = hdhomerun_output.use_output.as_ref() { - match &use_output { - TargetType::M3u => { hdhomerun_needs_m3u = true; } - TargetType::Xtream => { hdhomerun_needs_xtream = true; } - _ => return create_tuliprox_error_result!(TuliproxErrorKind::Info, "HdHomeRun output option `use_output` only accepts `m3u` or `xtream` for target: {}", self.name), - } - } - } - } - } - - if m3u_cnt > 1 || xtream_cnt > 1 || hdhr_cnt > 1 { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Multiple output formats with same type : {}", self.name); - } - - if strm_cnt > 0 && strm_needs_xtream && xtream_cnt == 0 { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "strm output with a username is only permitted when used in combination with xtream output: {}", self.name); - } - - if hdhr_cnt > 0 { - if xtream_cnt == 0 && m3u_cnt == 0 { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "HdHomeRun output is only permitted when used in combination with xtream or m3u output: {}", self.name); - } - if hdhomerun_needs_m3u && m3u_cnt == 0 { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "HdHomeRun output has `use_output=m3u` but no `m3u` output defined: {}", self.name); - } - if hdhomerun_needs_xtream && xtream_cnt == 0 { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "HdHomeRun output has `use_output=xtream` but no `xtream` output defined: {}", self.name); - } - } - - if let Some(watch) = &self.watch { - let regexps: Result, _> = watch.iter().map(|s| regex::Regex::new(s)).collect(); - match regexps { - Ok(watch_re) => self.t_watch_re = Some(watch_re), - Err(err) => { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Invalid watch regular expression: {}", err); - } - } - } - - match get_filter(&self.filter, templates) { - Ok(fltr) => { - // debug!("Filter: {}", fltr); - self.t_filter = Some(fltr); - if let Some(renames) = self.rename.as_mut() { - handle_tuliprox_error_result_list!(TuliproxErrorKind::Info, renames.iter_mut().map(|cr|cr.prepare(templates))); - } - if let Some(sort) = self.sort.as_mut() { - sort.prepare(templates)?; - } - Ok(()) - } - Err(err) => Err(err), - } - } pub fn filter(&self, provider: &ValueProvider) -> bool { - if let Some(filter) = self.t_filter.as_ref() { - return filter.filter(provider); - } - true + self.filter.filter(provider) } pub(crate) fn get_xtream_output(&self) -> Option<&XtreamTargetOutput> { @@ -339,4 +269,25 @@ impl ConfigTarget { .and_then(|options| options.force_redirect.as_ref()) .is_some_and(|flags| flags.has_cluster(item_type)) } +} + +macros::from_impl!(ConfigTarget); +impl From<&ConfigTargetDto> for ConfigTarget { + fn from(dto: &ConfigTargetDto) -> Self { + + Self { + id: dto.id, + enabled: dto.enabled, + name: dto.name.to_string(), + options: dto.options.clone(), + sort: dto.sort.as_ref().map(Into::into), + filter: dto.t_filter.as_ref().unwrap().clone(), + output: dto.output.iter().map(Into::into).collect(), + rename: dto.rename.as_ref().map(|l| l.iter().map(Into::into).collect()), + mapping_ids: dto.mapping.clone(), + mapping: Arc::new(ArcSwapOption::new(None)), + processing_order: dto.processing_order, + watch: dto.watch.as_ref().map(|list| list.iter().filter_map(|s| Regex::new(s).ok()).collect()) + } + } } \ No newline at end of file diff --git a/backend/src/model/config/trakt.rs b/backend/src/model/config/trakt.rs index 183b8fb98..7280c6359 100644 --- a/backend/src/model/config/trakt.rs +++ b/backend/src/model/config/trakt.rs @@ -1,167 +1,93 @@ -use serde::{Deserialize, Serialize}; -use crate::model::{PlaylistItem}; -use crate::utils::normalize_title_for_matching; +use shared::model::{PlaylistItem, TraktApiConfigDto, TraktConfigDto, TraktContentType, TraktListConfigDto}; +use crate::model::config::trakt_api::TraktMatchItem; +use crate::model::macros; -const TRAKT_API_KEY: &str = "0183a05ad97098d87287fe46da4ae286f434f32e8e951caad4cc147c947d79a3"; -const TRAKT_API_VERSION: &str = "2"; -const TRAKT_API_URL: &str = "https://api.trakt.tv"; -fn default_fuzzy_threshold() -> u8 { - 80 -} - -#[derive(Default, Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct TraktApiConfig { - #[serde(default)] - pub(crate) key: String, - #[serde(default)] - pub(crate) version: String, - #[serde(default)] - pub(crate) url: String, + pub key: String, + pub version: String, + pub url: String, } -impl TraktApiConfig { - pub fn prepare(&mut self) { - let key = self.key.trim(); - self.key = String::from(if key.is_empty() { TRAKT_API_KEY } else { key }); - let version = self.version.trim(); - self.version = String::from(if version.is_empty() { TRAKT_API_VERSION } else { version }); - let url = self.url.trim(); - self.url = String::from(if url.is_empty() { TRAKT_API_URL } else { url }); +macros::from_impl!(TraktApiConfig); +impl From<&TraktApiConfigDto> for TraktApiConfig { + fn from(dto: &TraktApiConfigDto) -> Self { + Self { + key: dto.key.to_string(), + version: dto.version.to_string(), + url: dto.url.to_string(), + } } } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] +impl From<&TraktApiConfig> for TraktApiConfigDto { + fn from(instance: &TraktApiConfig) -> Self { + Self { + key: instance.key.to_string(), + version: instance.version.to_string(), + url: instance.url.to_string(), + } + } +} + +#[derive(Debug, Clone)] pub struct TraktListConfig { pub user: String, pub list_slug: String, pub category_name: String, pub content_type: TraktContentType, - #[serde(default = "default_fuzzy_threshold")] pub fuzzy_match_threshold: u8, // Percentage (0-100) } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum TraktContentType { - Vod, - Series, - Both, -} - -impl Default for TraktContentType { - fn default() -> Self { - Self::Both +macros::from_impl!(TraktListConfig); +impl From<&TraktListConfigDto> for TraktListConfig { + fn from(dto: &TraktListConfigDto) -> Self { + Self { + user: dto.user.to_string(), + list_slug: dto.list_slug.to_string(), + category_name: dto.category_name.to_string(), + content_type: dto.content_type, + fuzzy_match_threshold: dto.fuzzy_match_threshold + } } } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] +impl From<&TraktListConfig> for TraktListConfigDto { + fn from(instance: &TraktListConfig) -> Self { + Self { + user: instance.user.to_string(), + list_slug: instance.list_slug.to_string(), + category_name: instance.category_name.to_string(), + content_type: instance.content_type, + fuzzy_match_threshold: instance.fuzzy_match_threshold + } + } +} + +#[derive(Debug, Clone)] pub struct TraktConfig { - #[serde(default)] pub api: TraktApiConfig, pub lists: Vec, } -impl TraktConfig { - pub fn prepare(&mut self) { - self.api.prepare(); - } -} - -// API Response structures -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TraktListItem { - pub id: u64, - pub rank: Option, - pub listed_at: String, - pub notes: Option, - #[serde(rename = "type")] - pub item_type: String, - pub movie: Option, - pub show: Option, - #[serde(skip)] - pub content_type: TraktContentType, -} - -impl TraktListItem { - pub fn prepare(&mut self) { - self.content_type = match self.item_type.as_str() { - "movie" => if self.movie.is_some() { TraktContentType::Vod } else { TraktContentType::Both }, - "show" => if self.show.is_some() { TraktContentType::Series } else { TraktContentType::Both }, - _ => TraktContentType::Both, +macros::from_impl!(TraktConfig); +impl From<&TraktConfigDto> for TraktConfig { + fn from(dto: &TraktConfigDto) -> Self { + Self { + api: TraktApiConfig::from(&dto.api), + lists: dto.lists.iter().map(Into::into).collect(), } } } - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TraktMovie { - pub ids: TraktIds, - pub title: String, - pub year: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TraktShow { - pub ids: TraktIds, - pub title: String, - pub year: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TraktIds { - pub trakt: u32, - pub slug: String, - pub tvdb: Option, - pub imdb: Option, - pub tmdb: Option, - pub tvrage: Option, -} - -// Internal matching structures -#[derive(Debug, Clone)] -pub struct TraktMatchItem<'a> { - pub title: &'a str, - pub normalized_title: String, - pub year: Option, - pub tmdb_id: Option, - pub trakt_id: u32, - pub content_type: TraktContentType, - pub rank: Option, -} - -impl<'a> TraktMatchItem<'a> { - pub fn from_trakt_list_item(item: &'a TraktListItem) -> Option { - match item.item_type.as_str() { - "movie" => { - item.movie.as_ref().map(|movie| TraktMatchItem { - title: movie.title.as_str(), - normalized_title: normalize_title_for_matching(movie.title.as_str()), - year: movie.year, - tmdb_id: movie.ids.tmdb, - trakt_id: movie.ids.trakt, - content_type: TraktContentType::Vod, - rank: item.rank, - }) - } - "show" => { - item.show.as_ref().map(|show| TraktMatchItem { - title: show.title.as_str(), - normalized_title: normalize_title_for_matching(show.title.as_str()), - year: show.year, - tmdb_id: show.ids.tmdb, - trakt_id: show.ids.trakt, - content_type: TraktContentType::Series, - rank: item.rank, - }) - } - _ => None, +impl From<&TraktConfig> for TraktConfigDto { + fn from(dto: &TraktConfig) -> Self { + Self { + api: TraktApiConfigDto::from(&dto.api), + lists: dto.lists.iter().map(TraktListConfigDto::from).collect(), } } } - // Matching results #[derive(Debug, Clone)] pub struct TraktMatchResult<'a> { diff --git a/backend/src/model/config/trakt_api.rs b/backend/src/model/config/trakt_api.rs new file mode 100644 index 000000000..abb4540dc --- /dev/null +++ b/backend/src/model/config/trakt_api.rs @@ -0,0 +1,94 @@ +use serde::{Deserialize, Serialize}; +use shared::model::TraktContentType; +use crate::utils::normalize_title_for_matching; + +// Trakt API Response structures +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TraktListItem { + pub id: u64, + pub rank: Option, + pub listed_at: String, + pub notes: Option, + #[serde(rename = "type")] + pub item_type: String, + pub movie: Option, + pub show: Option, + #[serde(skip)] + pub content_type: TraktContentType, +} + +impl TraktListItem { + pub fn prepare(&mut self) { + self.content_type = match self.item_type.as_str() { + "movie" => if self.movie.is_some() { TraktContentType::Vod } else { TraktContentType::Both }, + "show" => if self.show.is_some() { TraktContentType::Series } else { TraktContentType::Both }, + _ => TraktContentType::Both, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TraktMovie { + pub ids: TraktIds, + pub title: String, + pub year: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TraktShow { + pub ids: TraktIds, + pub title: String, + pub year: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TraktIds { + pub trakt: u32, + pub slug: String, + pub tvdb: Option, + pub imdb: Option, + pub tmdb: Option, + pub tvrage: Option, +} + +// Internal matching structures +#[derive(Debug, Clone)] +pub struct TraktMatchItem<'a> { + pub title: &'a str, + pub normalized_title: String, + pub year: Option, + pub tmdb_id: Option, + pub trakt_id: u32, + pub content_type: TraktContentType, + pub rank: Option, +} + +impl<'a> TraktMatchItem<'a> { + pub fn from_trakt_list_item(item: &'a TraktListItem) -> Option { + match item.item_type.as_str() { + "movie" => { + item.movie.as_ref().map(|movie| TraktMatchItem { + title: movie.title.as_str(), + normalized_title: normalize_title_for_matching(movie.title.as_str()), + year: movie.year, + tmdb_id: movie.ids.tmdb, + trakt_id: movie.ids.trakt, + content_type: TraktContentType::Vod, + rank: item.rank, + }) + } + "show" => { + item.show.as_ref().map(|show| TraktMatchItem { + title: show.title.as_str(), + normalized_title: normalize_title_for_matching(show.title.as_str()), + year: show.year, + tmdb_id: show.ids.tmdb, + trakt_id: show.ids.trakt, + content_type: TraktContentType::Series, + rank: item.rank, + }) + } + _ => None, + } + } +} \ No newline at end of file diff --git a/backend/src/model/config/video_download.rs b/backend/src/model/config/video_download.rs index eb072a509..4bed8e955 100644 --- a/backend/src/model/config/video_download.rs +++ b/backend/src/model/config/video_download.rs @@ -1,69 +1,63 @@ -use shared::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind}; use regex::Regex; -use std::borrow::BorrowMut; use std::collections::HashMap; +use shared::model::{VideoConfigDto, VideoDownloadConfigDto}; +use crate::model::macros; -pub const DEFAULT_USER_AGENT: &str = "VLC/3.0.16 LibVLC/3.0.16"; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct VideoDownloadConfig { - #[serde(default)] pub headers: HashMap, - #[serde(default, skip_serializing_if = "Option::is_none")] pub directory: Option, - #[serde(default)] pub organize_into_directories: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub episode_pattern: Option, - #[serde(default, skip_serializing, skip_deserializing)] - pub t_re_episode_pattern: Option, + pub episode_pattern: Option, } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] + +macros::from_impl!(VideoDownloadConfig); +impl From<&VideoDownloadConfigDto> for VideoDownloadConfig { + fn from(dto: &VideoDownloadConfigDto) -> Self { + Self { + headers: dto.headers.clone(), + directory: dto.directory.clone(), + organize_into_directories: dto.organize_into_directories, + episode_pattern: dto.episode_pattern.as_ref().map(|s| Regex::new(s).unwrap()), + } + } +} + +impl From<&VideoDownloadConfig> for VideoDownloadConfigDto { + fn from(instance: &VideoDownloadConfig) -> Self { + Self { + headers: instance.headers.clone(), + directory: instance.directory.clone(), + organize_into_directories: instance.organize_into_directories, + episode_pattern: instance.episode_pattern.as_ref().map(std::string::ToString::to_string), + } + } +} + +#[derive(Debug, Clone)] pub struct VideoConfig { - #[serde(default)] pub extensions: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] pub download: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub web_search: Option, } - -impl VideoConfig { - /// # Panics - /// - /// Will panic if default `RegEx` gets invalid - pub fn prepare(&mut self) -> Result<(), TuliproxError> { - if self.extensions.is_empty() { - self.extensions = ["mkv", "avi", "mp4", "mpeg", "divx", "mov"] - .iter() - .map(|&arg| arg.to_string()) - .collect(); +macros::from_impl!(VideoConfig); +impl From<&VideoConfigDto> for VideoConfig { + fn from(dto: &VideoConfigDto) -> Self { + Self { + extensions: dto.extensions.clone(), + download: dto.download.as_ref().map(Into::into), + web_search: dto.web_search.clone(), } - match &mut self.download { - None => {} - Some(downl) => { - if downl.headers.is_empty() { - downl.headers.borrow_mut().insert("Accept".to_string(), "video/*".to_string()); - downl.headers.borrow_mut().insert("User-Agent".to_string(), DEFAULT_USER_AGENT.to_string()); - } + } +} - if let Some(episode_pattern) = &downl.episode_pattern { - if !episode_pattern.is_empty() { - match regex::Regex::new(episode_pattern) { - Ok(pattern) => { - downl.t_re_episode_pattern = Some(pattern); - } - Err(err) => { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "cant parse regex: {episode_pattern} {err}"); - } - } - } - } - } +impl From<&VideoConfig> for VideoConfigDto { + fn from(instance: &VideoConfig) -> Self { + Self { + extensions: instance.extensions.clone(), + download: instance.download.as_ref().map(Into::into), + web_search: instance.web_search.clone(), } - Ok(()) } } \ No newline at end of file diff --git a/backend/src/model/config/web_auth.rs b/backend/src/model/config/web_auth.rs index 7e4901d17..f1b8bd150 100644 --- a/backend/src/model/config/web_auth.rs +++ b/backend/src/model/config/web_auth.rs @@ -1,24 +1,45 @@ -use shared::utils::default_as_true; use std::fs::File; use std::io::BufRead; use std::path::PathBuf; use crate::auth::UserCredential; use shared::error::{TuliproxError, TuliproxErrorKind, create_tuliprox_error_result}; +use shared::model::WebAuthConfigDto; +use crate::model::macros; use crate::utils; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct WebAuthConfig { - #[serde(default = "default_as_true")] pub enabled: bool, pub issuer: String, pub secret: String, - #[serde(default, skip_serializing_if = "Option::is_none")] pub userfile: Option, - #[serde(skip_serializing, skip_deserializing)] pub t_users: Option>, } +macros::from_impl!(WebAuthConfig); +impl From<&WebAuthConfigDto> for WebAuthConfig { + fn from(dto: &WebAuthConfigDto) -> Self { + Self { + enabled: dto.enabled, + issuer: dto.issuer.to_string(), + secret: dto.secret.to_string(), + userfile: dto.userfile.clone(), + t_users: None, + } + } +} + +impl From<&WebAuthConfig> for WebAuthConfigDto { + fn from(instance: &WebAuthConfig) -> Self { + Self { + enabled: instance.enabled, + issuer: instance.issuer.to_string(), + secret: instance.secret.to_string(), + userfile: instance.userfile.clone(), + } + } +} + impl WebAuthConfig { pub fn prepare(&mut self, config_path: &str) -> Result<(), TuliproxError> { let userfile_name = self.userfile.as_ref().map_or_else(|| utils::get_default_user_file_path(config_path), std::borrow::ToOwned::to_owned); diff --git a/backend/src/model/config/web_ui.rs b/backend/src/model/config/web_ui.rs new file mode 100644 index 000000000..1e9caf72a --- /dev/null +++ b/backend/src/model/config/web_ui.rs @@ -0,0 +1,49 @@ +use shared::error::TuliproxError; +use shared::model::WebUiConfigDto; +use crate::model::{macros, WebAuthConfig}; + +#[derive(Debug, Clone)] +pub struct WebUiConfig { + pub enabled: bool, + pub user_ui_enabled: bool, + pub path: Option, + pub auth: Option, + pub player_server: Option, +} + +impl WebUiConfig { + pub fn prepare(&mut self, config_path: &str) -> Result<(), TuliproxError> { + if let Some(web_auth) = &mut self.auth { + if web_auth.enabled { + web_auth.prepare(config_path)?; + } else { + self.auth = None; + } + } + Ok(()) + } +} + +macros::from_impl!(WebUiConfig); +impl From<&WebUiConfigDto> for WebUiConfig { + fn from(dto: &WebUiConfigDto) -> Self { + Self { + enabled: dto.enabled, + user_ui_enabled: dto.user_ui_enabled, + path: dto.path.clone(), + auth: dto.auth.as_ref().map(Into::into), + player_server: dto.player_server.clone(), + } + } +} +impl From<&WebUiConfig> for WebUiConfigDto { + fn from(instance: &WebUiConfig) -> Self { + Self { + enabled: instance.enabled, + user_ui_enabled: instance.user_ui_enabled, + path: instance.path.clone(), + auth: instance.auth.as_ref().map(Into::into), + player_server: instance.player_server.clone(), + } + } +} \ No newline at end of file diff --git a/backend/src/model/mapping.rs b/backend/src/model/mapping.rs index 01f74f131..788ea4ea2 100644 --- a/backend/src/model/mapping.rs +++ b/backend/src/model/mapping.rs @@ -1,143 +1,7 @@ -use enum_iterator::Sequence; -use std::fmt::Display; -use std::str::FromStr; -use std::sync::atomic::AtomicU32; -use std::sync::Arc; -use log::{trace}; -use crate::foundation::filter::{apply_templates_to_pattern_single, get_filter, prepare_templates, Filter, PatternTemplate}; -use crate::foundation::mapper::MapperScript; -use crate::model::valid_property; -use shared::error::{create_tuliprox_error_result, info_err}; -use shared::error::{TuliproxError, TuliproxErrorKind}; - -pub const COUNTER_FIELDS: &[&str] = &["name", "title", "caption", "chno"]; - -pub const MAPPER_FIELDS: &[&str] = &[ - "name", "title", "caption", "group", "id", "chno", "logo", - "logo_small", "parent_code", "audio_track", - "time_shift", "rec", "url", "epg_channel_id", "epg_id" -]; - -#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq)] -pub enum CounterModifier { - #[serde(rename = "assign")] - Assign, - #[serde(rename = "suffix")] - Suffix, - #[serde(rename = "prefix")] - Prefix, -} - -impl Default for CounterModifier { - fn default() -> Self { - Self::Assign - } -} - -impl CounterModifier { - const ASSIGN: &'static str = "assign"; - const SUFFIX: &'static str = "suffix"; - const PREFIX: &'static str = "prefix"; -} - -impl Display for CounterModifier { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", match self { - Self::Assign => Self::ASSIGN, - Self::Suffix => Self::SUFFIX, - Self::Prefix => Self::PREFIX, - }) - } -} - -impl FromStr for CounterModifier { - type Err = TuliproxError; - - fn from_str(s: &str) -> Result { - if s.eq("assign") { - Ok(Self::Assign) - } else if s.eq("suffix") { - Ok(Self::Suffix) - } else if s.eq("prefix") { - Ok(Self::Prefix) - } else { - create_tuliprox_error_result!(TuliproxErrorKind::Info, "Unknown CounterModifier: {}", s) - } - } -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -pub struct MappingCounterDefinition { - pub filter: String, - pub field: String, - #[serde(default)] - pub concat: String, - #[serde(default)] - pub modifier: CounterModifier, - #[serde(default)] - pub value: u32, - #[serde(default)] - pub padding: u8, -} - -#[derive(Debug, Clone)] -pub struct MappingCounter { - pub filter: Filter, - pub field: String, - pub concat: String, - pub modifier: CounterModifier, - pub value: Arc, - pub padding: u8, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(tag = "modifier", rename_all = "snake_case")] -pub enum MapperOperation { - Lowercase { field: String }, - Uppercase { field: String }, - Capitalize { field: String }, - Suffix { field: String, value: String }, - Prefix { field: String, value: String }, - Set { field: String, value: String }, - Copy { field: String, source: String }, -} - -impl MapperOperation { - pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { - match self { - MapperOperation::Lowercase {ref field } - | MapperOperation::Uppercase { ref field } - | MapperOperation::Capitalize { ref field } => { - if !valid_property!(field.as_str(), MAPPER_FIELDS) { - return Err(info_err!(format!("Invalid mapper attribute field {field}"))); - } - } - - MapperOperation::Copy { ref field, ref source } => { - if !valid_property!(field.as_str(), MAPPER_FIELDS) { - return Err(info_err!(format!("Invalid mapper attribute field {field}"))); - } - if !valid_property!(source.as_str(), MAPPER_FIELDS) { - return Err(info_err!(format!("Invalid mapper source field {source}"))); - } - } - - MapperOperation::Suffix { ref field, ref mut value } - | MapperOperation::Prefix { ref field, ref mut value } - | MapperOperation::Set { ref field, ref mut value } => { - if !valid_property!(field.as_str(), MAPPER_FIELDS) { - return Err(info_err!(format!("Invalid mapper attribute field {field}"))); - } - - if templates.is_some() { - *value = apply_templates_to_pattern_single(value, templates)?; - } - } - } - - Ok(()) - } -} +use shared::foundation::filter::Filter; +use shared::foundation::mapper::MapperScript; +use shared::model::{MapperDto, MappingCounter, MappingCounterDefinition, MappingDefinitionDto, MappingDto, MappingsDto, PatternTemplate}; +use crate::model::macros; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] pub struct Mapper { @@ -149,27 +13,18 @@ pub struct Mapper { pub t_script: Option, } -impl Mapper { - /// # Panics - /// - /// Will panic if default `RegEx` gets invalid - pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { - match get_filter(&self.filter, templates) { - Ok(filter) => self.t_filter = Some(filter), - Err(err) => return Err(err), +macros::from_impl!(Mapper); +impl From<&MapperDto> for Mapper { + fn from(dto: &MapperDto) -> Self { + Self { + filter: dto.filter.to_string(), + script: dto.script.to_string(), + t_filter: dto.t_filter.clone(), + t_script: dto.t_script.clone(), } - let script = if templates.is_some() { - apply_templates_to_pattern_single(&self.script, templates)? - } else { - self.script.to_string() - }; - trace!("Mapper script: {script}"); - self.t_script = Some(MapperScript::parse(&script, templates)?); - Ok(()) } } - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] pub struct Mapping { pub id: String, @@ -183,39 +38,16 @@ pub struct Mapping { pub(crate) templates: Option> } -impl Mapping { - pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { - self.templates = templates.map(|t| t.iter().map(PatternTemplate::clone).collect::>()); - if let Some(mapper_list) = &mut self.mapper { - for mapper in mapper_list { - mapper.prepare(templates)?; - } +impl From<&MappingDto> for Mapping { + fn from(dto: &MappingDto) -> Self { + Self { + id: dto.id.to_string(), + match_as_ascii: dto.match_as_ascii, + mapper: dto.mapper.as_ref().map(|l| l.iter().map(Mapper::from).collect()), + counter: dto.counter.clone(), + t_counter: dto.t_counter.clone(), + templates: dto.templates.clone(), } - - if let Some(counter_def_list) = &self.counter { - let mut counters = vec![]; - for def in counter_def_list { - if !valid_property!(def.field.as_str(), COUNTER_FIELDS) { - return Err(info_err!(format!("Invalid counter field {}", def.field))); - } - match get_filter(&def.filter, templates) { - Ok(flt) => { - counters.push(MappingCounter { - filter: flt, - field: def.field.clone(), - concat: def.concat.clone(), - modifier: def.modifier, - value: Arc::new(AtomicU32::new(def.value)), - padding: def.padding, - }); - } - Err(e) => return Err(info_err!(e.to_string())) - } - } - self.t_counter = Some(counters); - } - - Ok(()) } } @@ -225,21 +57,13 @@ pub struct MappingDefinition { pub mapping: Vec, } -impl MappingDefinition { - pub fn prepare(&mut self) -> Result<(), TuliproxError> { - if let Some(templates) = &mut self.templates { - match prepare_templates(templates) { - Ok(tmplts) => { - self.templates = Some(tmplts); - } - Err(err) => return Err(err), - } +macros::from_impl!(MappingDefinition); +impl From<&MappingDefinitionDto> for MappingDefinition { + fn from(dto: &MappingDefinitionDto) -> Self { + Self { + templates: dto.templates.clone(), + mapping: dto.mapping.iter().map(Into::into).collect(), } - for mapping in &mut self.mapping { - let template_list = self.templates.as_ref(); - mapping.prepare(template_list)?; - } - Ok(()) } } @@ -249,9 +73,6 @@ pub struct Mappings { } impl Mappings { - pub fn prepare(&mut self) -> Result<(), TuliproxError> { - self.mappings.prepare() - } pub fn get_mapping(&self, mapping_id: &str) -> Option { for mapping in &self.mappings.mapping { @@ -262,3 +83,12 @@ impl Mappings { None } } + +macros::from_impl!(Mappings); +impl From<&MappingsDto> for Mappings { + fn from(dto: &MappingsDto) -> Self { + Mappings { + mappings: MappingDefinition::from(&dto.mappings), + } + } +} diff --git a/backend/src/model/mod.rs b/backend/src/model/mod.rs index bc343a3af..514e893c1 100644 --- a/backend/src/model/mod.rs +++ b/backend/src/model/mod.rs @@ -5,7 +5,6 @@ mod xmltv; mod xtream; mod healthcheck; mod playlist_categories; -mod xtream_const; mod config; pub use self::playlist::*; @@ -15,5 +14,5 @@ pub use self::xmltv::*; pub use self::xtream::*; pub use self::healthcheck::*; pub use self::playlist_categories::*; -pub use self::xtream_const::*; +pub use shared::model::xtream_const::*; pub use self::config::*; diff --git a/backend/src/model/playlist.rs b/backend/src/model/playlist.rs index e2d732fac..0ed9b196c 100644 --- a/backend/src/model/playlist.rs +++ b/backend/src/model/playlist.rs @@ -1,16 +1,5 @@ -use crate::model::xtream::{xtream_playlistitem_to_document, XtreamMappingOptions}; -use crate::model::xtream_const; -use crate::model::{TVGuide, ProxyUserCredentials, ConfigInput, ConfigTargetOptions}; -use crate::utils::request::extract_extension_from_url; -use crate::utils::{generate_playlist_uuid, get_provider_id}; -use crate::utils::{get_string_from_serde_value, get_u64_from_serde_value}; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; -use std::borrow::Cow; -use shared::model::{PlaylistEntry, PlaylistItemType, UUIDType, XtreamCluster}; -// https://de.wikipedia.org/wiki/M3U -// https://siptv.eu/howto/playlist.html - +use crate::model::{TVGuide, ConfigInput}; +use shared::model::{PlaylistGroup}; #[derive(Debug, Clone)] pub struct FetchedPlaylist<'a> { // Contains playlist for one input @@ -19,6 +8,7 @@ pub struct FetchedPlaylist<'a> { // Contains playlist for one input pub epg: Option, } + impl FetchedPlaylist<'_> { pub fn update_playlist(&mut self, plg: &PlaylistGroup) { for grp in &mut self.playlistgroups { @@ -29,516 +19,3 @@ impl FetchedPlaylist<'_> { } } } - - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct PlaylistItemHeader { - pub uuid: UUIDType, // calculated - pub id: String, // provider id - pub virtual_id: u32, // virtual id - pub name: String, - pub chno: String, - pub logo: String, - pub logo_small: String, - pub group: String, - pub title: String, - pub parent_code: String, - pub audio_track: String, - pub time_shift: String, - pub rec: String, - pub url: String, - pub epg_channel_id: Option, - pub xtream_cluster: XtreamCluster, - pub additional_properties: Option, - #[serde(default, skip_serializing, skip_deserializing)] - pub item_type: PlaylistItemType, - #[serde(default)] - pub category_id: u32, - pub input_name: String, -} - -impl PlaylistItemHeader { - pub fn gen_uuid(&mut self) { - self.uuid = generate_playlist_uuid(&self.input_name, &self.id, self.item_type, &self.url); - } - pub const fn get_uuid(&self) -> &UUIDType { - &self.uuid - } - - pub fn get_provider_id(&mut self) -> Option { - match get_provider_id(&self.id, &self.url) { - None => None, - Some(newid) => { - self.id = newid.to_string(); - Some(newid) - } - } - } - - pub fn get_additional_property(&self, field: &str) -> Option<&Value> { - self.additional_properties.as_ref().and_then(|v| match v { - Value::Object(map) => { - map.get(field) - } - _ => None, - }) - } - // pub fn get_additional_property_as_u32(&self, field: &str) -> Option { - // match self.get_additional_property(field) { - // Some(value) => get_u32_from_serde_value(value), - // None => None - // } - // } - pub fn get_additional_property_as_u64(&self, field: &str) -> Option { - match self.get_additional_property(field) { - Some(value) => get_u64_from_serde_value(value), - None => None - } - } - - pub fn get_additional_property_as_str(&self, field: &str) -> Option { - match self.get_additional_property(field) { - Some(value) => get_string_from_serde_value(value), - None => None - } - } -} - -macro_rules! to_m3u_non_empty_fields { - ($header:expr, $line:expr, $(($prop:ident, $field:expr)),*;) => { - $( - if !$header.$prop.is_empty() { - $line = format!("{} {}=\"{}\"", $line, $field, $header.$prop); - } - )* - }; -} - -macro_rules! to_m3u_resource_non_empty_fields { - ($header:expr, $url:expr, $line:expr, $(($prop:ident, $field:expr)),*;) => { - $( - if !$header.$prop.is_empty() { - $line = format!("{} {}=\"{}/{}\"", $line, $field, $url, stringify!($prop)); - } - )* - }; -} - -macro_rules! generate_field_accessor_impl_for_playlist_item_header { - ($($prop:ident),*;) => { - impl shared::model::FieldGetAccessor for PlaylistItemHeader { - fn get_field(&self, field: &str) -> Option> { - let field = field.to_lowercase(); - match field.as_str() { - $( - stringify!($prop) => Some(Cow::Borrowed(&self.$prop)), - )* - "input" => Some(Cow::Borrowed(self.input_name.as_str())), - "type" => Some(Cow::Owned(self.item_type.to_string())), - "caption" => Some(if self.title.is_empty() { Cow::Borrowed(&self.name) } else { Cow::Borrowed(&self.title) }), - "epg_channel_id" | "epg_id" => self.epg_channel_id.as_ref().map(|s| Cow::Borrowed(s.as_str())), - _ => None, - } - } - } - impl shared::model::FieldSetAccessor for PlaylistItemHeader { - fn set_field(&mut self, field: &str, value: &str) -> bool { - let field = field.to_lowercase(); - let val = String::from(value); - match field.as_str() { - $( - stringify!($prop) => { - self.$prop = val; - true - } - )* - "caption" => { - self.title = val.clone(); - self.name = val; - true - } - "epg_channel_id" | "epg_id" => { - self.epg_channel_id = Some(value.to_owned()); - true - } - _ => false, - } - } - } - } -} - -generate_field_accessor_impl_for_playlist_item_header!(id, /*virtual_id,*/ name, chno, logo, logo_small, group, title, parent_code, audio_track, time_shift, rec, url;); - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct M3uPlaylistItem { - pub virtual_id: u32, - pub provider_id: String, - pub name: String, - pub chno: String, - pub logo: String, - pub logo_small: String, - pub group: String, - pub title: String, - pub parent_code: String, - pub audio_track: String, - pub time_shift: String, - pub rec: String, - pub url: String, - pub epg_channel_id: Option, - pub input_name: String, - pub item_type: PlaylistItemType, - #[serde(skip)] - pub t_stream_url: String, - #[serde(skip)] - pub t_resource_url: Option, -} - -impl M3uPlaylistItem { - #[allow(clippy::missing_panics_doc)] - pub fn to_m3u(&self, target_options: Option<&ConfigTargetOptions>, rewrite_urls: bool) -> String { - let options = target_options.as_ref(); - let ignore_logo = options.is_some_and(|o| o.ignore_logo); - let mut line = format!("#EXTINF:-1 tvg-id=\"{}\" tvg-name=\"{}\" group-title=\"{}\"", - self.epg_channel_id.as_ref().map_or("", |o| o.as_ref()), - self.name, self.group); - - if !ignore_logo { - if rewrite_urls && self.t_resource_url.is_some() { - to_m3u_resource_non_empty_fields!(self, self.t_resource_url.as_ref().unwrap(), line, (logo, "tvg-logo"), (logo_small, "tvg-logo-small");); - } else { - to_m3u_non_empty_fields!(self, line, (logo, "tvg-logo"), (logo_small, "tvg-logo-small");); - } - } - - to_m3u_non_empty_fields!(self, line, - (chno, "tvg-chno"), - (parent_code, "parent-code"), - (audio_track, "audio-track"), - (time_shift, "timeshift"), - (rec, "tvg-rec");); - - let url = if self.t_stream_url.is_empty() { &self.url } else { &self.t_stream_url }; - format!("{line},{}\n{url}", self.title, ) - } -} - -impl PlaylistEntry for M3uPlaylistItem { - #[inline] - fn get_virtual_id(&self) -> u32 { - self.virtual_id - } - - fn get_provider_id(&self) -> Option { - get_provider_id(&self.provider_id, &self.url) - } - #[inline] - fn get_category_id(&self) -> Option { - None - } - #[inline] - fn get_provider_url(&self) -> String { - self.url.to_string() - } - - fn get_uuid(&self) -> UUIDType { - generate_playlist_uuid(&self.input_name, &self.provider_id, self.item_type, &self.url) - } - - #[inline] - fn get_item_type(&self) -> PlaylistItemType { - self.item_type - } -} - -macro_rules! generate_field_accessor_impl_for_m3u_playlist_item { - ($($prop:ident),*;) => { - impl shared::model::FieldGetAccessor for M3uPlaylistItem { - fn get_field(&self, field: &str) -> Option> { - let field = field.to_lowercase(); - match field.as_str() { - $( - stringify!($prop) => Some(Cow::Borrowed(&self.$prop)), - )* - "caption" => Some(if self.title.is_empty() { Cow::Borrowed(&self.name) } else { Cow::Borrowed(&self.title) }), - "epg_channel_id" | "epg_id" => self.epg_channel_id.as_ref().map(|s| Cow::Borrowed(s.as_str())), - _ => None, - } - } - } - } -} - -generate_field_accessor_impl_for_m3u_playlist_item!(provider_id, name, chno, logo, logo_small, group, title, parent_code, audio_track, time_shift, rec, url;); - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct XtreamPlaylistItem { - pub virtual_id: u32, - pub provider_id: u32, - pub name: String, - pub logo: String, - pub logo_small: String, - pub group: String, - pub title: String, - pub parent_code: String, - pub rec: String, - pub url: String, - pub epg_channel_id: Option, - pub xtream_cluster: XtreamCluster, - pub additional_properties: Option, - pub item_type: PlaylistItemType, - pub category_id: u32, - pub input_name: String, - pub channel_no: u32, -} - -impl XtreamPlaylistItem { - pub fn to_doc(&self, url: &str, options: &XtreamMappingOptions, user: &ProxyUserCredentials) -> Value { - xtream_playlistitem_to_document(self, url, options, user) - } - - pub fn get_additional_property(&self, field: &str) -> Option { - if let Some(json) = self.additional_properties.as_ref() { - if let Ok(Value::Object(props)) = serde_json::from_str(json) { - return props.get(field).cloned(); - } - } - None - } -} - -impl PlaylistEntry for XtreamPlaylistItem { - #[inline] - fn get_virtual_id(&self) -> u32 { - self.virtual_id - } - #[inline] - fn get_provider_id(&self) -> Option { - Some(self.provider_id) - } - #[inline] - fn get_category_id(&self) -> Option { - None - } - #[inline] - fn get_provider_url(&self) -> String { - self.url.to_string() - } - - #[inline] - fn get_uuid(&self) -> UUIDType { - generate_playlist_uuid(&self.input_name, &self.provider_id.to_string(), self.item_type, &self.url) - } - #[inline] - fn get_item_type(&self) -> PlaylistItemType { - self.item_type - } -} - -pub fn get_backdrop_path_value<'a>(field: &'a str, value: Option<&'a Value>) -> Option> { - match value { - Some(Value::String(url)) => Some(Cow::Borrowed(url)), - Some(Value::Array(values)) => { - match values.as_slice() { - [Value::String(single)] => Some(Cow::Borrowed(single)), - multiple if !multiple.is_empty() => { - if let Some(index) = field.rfind('_') { - if let Ok(bd_index) = field[index + 1..].parse::() { - if let Some(Value::String(selected)) = multiple.get(bd_index) { - return Some(Cow::Borrowed(selected)); - } - } - } - if let Value::String(url) = &multiple[0] { - Some(Cow::Borrowed(url)) - } else { - None - } - } - _ => None, - } - } - _ => None, - } -} - -macro_rules! generate_field_accessor_impl_for_xtream_playlist_item { - ($($prop:ident),*;) => { - impl shared::model::FieldGetAccessor for XtreamPlaylistItem { - fn get_field(&self, field: &str) -> Option> { - let field = field.to_lowercase(); - match field.as_str() { - $( - stringify!($prop) => Some(Cow::Borrowed(&self.$prop)), - )* - "caption" => Some(if self.title.is_empty() { Cow::Borrowed(&self.name) } else { Cow::Borrowed(&self.title) }), - "epg_channel_id" | "epg_id" => self.epg_channel_id.as_ref().map(|s| Cow::Borrowed(s.as_str())), - _ => { - if field.starts_with(xtream_const::XC_PROP_BACKDROP_PATH) || field == xtream_const::XC_PROP_COVER { - let props = self.additional_properties.as_ref().and_then(|add_props| serde_json::from_str::>(add_props).ok()); - return match props { - Some(doc) => { - return if field == xtream_const::XC_PROP_COVER { - doc.get(&field).and_then(|value| value.as_str()).map(|s| Cow::::Owned(s.to_owned())) - } else { - get_backdrop_path_value(&field, doc.get(xtream_const::XC_PROP_BACKDROP_PATH)).map(|s| Cow::::Owned(s.to_string())) - } - } - _=> None, - } - } - None - }, - } - } - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PlaylistItem { - pub header: PlaylistItemHeader, -} - -generate_field_accessor_impl_for_xtream_playlist_item!(name, logo, logo_small, group, title, parent_code, rec, url;); - -impl PlaylistItem { - pub fn to_m3u(&self) -> M3uPlaylistItem { - let header = &self.header; - M3uPlaylistItem { - virtual_id: header.virtual_id, - provider_id: header.id.to_string(), - name: if header.item_type == PlaylistItemType::Series { &header.title } else { &header.name }.to_string(), - chno: header.chno.to_string(), - logo: header.logo.to_string(), - logo_small: header.logo_small.to_string(), - group: header.group.to_string(), - title: header.title.to_string(), - parent_code: header.parent_code.to_string(), - audio_track: header.audio_track.to_string(), - time_shift: header.time_shift.to_string(), - rec: header.rec.to_string(), - url: header.url.to_string(), - epg_channel_id: header.epg_channel_id.clone(), - input_name: header.input_name.to_string(), - item_type: header.item_type, - t_stream_url: header.url.to_string(), - t_resource_url: None, - } - } - - pub fn to_xtream(&self) -> XtreamPlaylistItem { - let header = &self.header; - let provider_id = header.id.parse::().unwrap_or_default(); - let mut additional_properties = None; - if header.xtream_cluster != XtreamCluster::Live { - let add_ext = match header.get_additional_property("container_extension") { - None => true, - Some(ext) => ext.as_str().is_none_or(str::is_empty) - }; - if add_ext { - if let Some(cont_ext) = extract_extension_from_url(&header.url) { - let ext = if let Some(stripped) = cont_ext.strip_prefix('.') { stripped } else { cont_ext }; - let mut result = match header.additional_properties.as_ref() { - None => Map::new(), - Some(props) => { - if let Value::Object(map) = props { - map.clone() - } else { - Map::new() - } - } - }; - result.insert("container_extension".to_string(), Value::String(ext.to_string())); - additional_properties = serde_json::to_string(&Value::Object(result)).ok(); - } - } - } - if additional_properties.is_none() { - additional_properties = header.additional_properties.as_ref().and_then(|props| { - serde_json::to_string(props).ok() - }); - } - // let additional_properties = header.additional_properties.as_ref().and_then(|props| { - // serde_json::to_string(props).ok() - // }); - - XtreamPlaylistItem { - virtual_id: header.virtual_id, - provider_id, - name: if header.item_type == PlaylistItemType::Series { &header.title } else { &header.name }.to_string(), - logo: header.logo.to_string(), - logo_small: header.logo_small.to_string(), - group: header.group.to_string(), - title: header.title.to_string(), - parent_code: header.parent_code.to_string(), - rec: header.rec.to_string(), - url: header.url.to_string(), - epg_channel_id: header.epg_channel_id.clone(), - xtream_cluster: header.xtream_cluster, - additional_properties, - item_type: header.item_type, - category_id: header.category_id, - input_name: header.input_name.to_string(), - channel_no: header.chno.parse::().unwrap_or(0), - } - } -} - -impl PlaylistEntry for PlaylistItem { - #[inline] - fn get_virtual_id(&self) -> u32 { - self.header.virtual_id - } - - fn get_provider_id(&self) -> Option { - let header = &self.header; - get_provider_id(&header.id, &header.url) - } - - #[inline] - fn get_category_id(&self) -> Option { - None - } - - #[inline] - fn get_provider_url(&self) -> String { - self.header.url.to_string() - } - #[inline] - fn get_uuid(&self) -> UUIDType { - let header = &self.header; - generate_playlist_uuid(&header.input_name, &header.id, header.item_type, &header.url) - } - - #[inline] - fn get_item_type(&self) -> PlaylistItemType { - self.header.item_type - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PlaylistGroup { - pub id: u32, - pub title: String, - pub channels: Vec, - #[serde(skip_serializing, skip_deserializing)] - pub xtream_cluster: XtreamCluster, -} - -impl PlaylistGroup { - #[inline] - pub fn on_load(&mut self) { - for pl in &mut self.channels { - pl.header.gen_uuid(); - } - } - - #[inline] - pub fn filter_count(&self, filter: F) -> usize - where - F: Fn(&PlaylistItem) -> bool, - { - self.channels.iter().filter(|&c| filter(c)).count() - } -} diff --git a/backend/src/model/stats.rs b/backend/src/model/stats.rs index e91a69134..9488b059d 100644 --- a/backend/src/model/stats.rs +++ b/backend/src/model/stats.rs @@ -1,6 +1,6 @@ use std::fmt::{Display}; use serde::{Serialize, Serializer}; -use crate::model::InputType; +use shared::model::InputType; pub fn format_elapsed_time(seconds: u64) -> String { if seconds < 60 { diff --git a/backend/src/model/xtream.rs b/backend/src/model/xtream.rs index c5ac2977c..584e06fd6 100644 --- a/backend/src/model/xtream.rs +++ b/backend/src/model/xtream.rs @@ -1,10 +1,8 @@ -use crate::model::{ProxyUserCredentials}; -use crate::model::{Config, ConfigTarget, XtreamTargetOutput}; -use crate::model::{PlaylistItem,XtreamPlaylistItem}; -use crate::utils::{deserialize_as_option_rc_string, deserialize_as_rc_string, deserialize_as_string_array, deserialize_number_from_string}; -use crate::model::xtream_const; -use crate::utils::{opt_string_or_number_u32, string_default_on_null, string_or_number_f64, string_or_number_u32}; -use shared::utils::{get_non_empty_str}; +use crate::model::{AppConfig, ProxyUserCredentials}; +use crate::model::{ConfigTarget, XtreamTargetOutput}; +use shared::model::{xtream_const, PlaylistItem,XtreamPlaylistItem}; +use shared::utils::{deserialize_as_option_rc_string, deserialize_as_rc_string, deserialize_as_string_array, deserialize_number_from_string, + opt_string_or_number_u32, string_default_on_null, string_or_number_f64, string_or_number_u32, get_non_empty_str}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::collections::HashMap; @@ -345,7 +343,6 @@ pub struct XtreamSeriesInfo { pub episodes: Option>>, } - impl XtreamSeriesInfoEpisode { pub fn get_additional_properties(&self, series_info: &XtreamSeriesInfo) -> Option { let mut result = Map::new(); @@ -386,13 +383,13 @@ pub struct XtreamMappingOptions { } impl XtreamMappingOptions { - pub fn from_target_options(target: &ConfigTarget, target_output: &XtreamTargetOutput, cfg: &Config) -> Self { + pub fn from_target_options(target: &ConfigTarget, target_output: &XtreamTargetOutput, cfg: &AppConfig) -> Self { Self { skip_live_direct_source: target_output.skip_live_direct_source, skip_video_direct_source: target_output.skip_video_direct_source, skip_series_direct_source: target_output.skip_series_direct_source, rewrite_resource_url: cfg.is_reverse_proxy_resource_rewrite_enabled(), - force_redirect: target.options.as_ref().and_then(|o| o.force_redirect.clone()), + force_redirect: target.options.as_ref().and_then(|o| o.force_redirect), } } } diff --git a/backend/src/modules.rs b/backend/src/modules.rs index 250cec351..1838eab07 100644 --- a/backend/src/modules.rs +++ b/backend/src/modules.rs @@ -4,8 +4,6 @@ macro_rules! include_modules { extern crate core; extern crate env_logger; extern crate pest; - #[macro_use] - extern crate pest_derive; pub mod api; pub mod auth; pub mod messaging; @@ -14,7 +12,6 @@ macro_rules! include_modules { pub mod repository; pub mod utils; pub mod tools; - pub mod foundation; } } diff --git a/backend/src/processing/parser/hls.rs b/backend/src/processing/parser/hls.rs index dc7111cb6..fc33a4ae3 100644 --- a/backend/src/processing/parser/hls.rs +++ b/backend/src/processing/parser/hls.rs @@ -104,7 +104,7 @@ pub fn rewrite_hls(user: &ProxyUserCredentials, props: &RewriteHlsProps) -> Stri #[cfg(test)] mod test { use rand::RngCore; - use crate::utils::u32_to_base64; + use shared::utils::u32_to_base64; #[test] fn test_token_size() { diff --git a/backend/src/processing/parser/m3u.rs b/backend/src/processing/parser/m3u.rs index 70b8d7981..33efaf6b3 100644 --- a/backend/src/processing/parser/m3u.rs +++ b/backend/src/processing/parser/m3u.rs @@ -1,8 +1,7 @@ use std::borrow::BorrowMut; -use shared::model::{PlaylistItemType, XtreamCluster}; use crate::model::{Config, ConfigInput}; -use crate::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader}; -use crate::utils::{extract_id_from_url}; +use shared::model::{PlaylistItemType, XtreamCluster, PlaylistGroup, PlaylistItem, PlaylistItemHeader}; +use shared::utils::{extract_id_from_url}; // other implementations like calculating text_distance on all titles took too much time diff --git a/backend/src/processing/parser/xmltv.rs b/backend/src/processing/parser/xmltv.rs index 88a34c0db..9eb1e3e53 100644 --- a/backend/src/processing/parser/xmltv.rs +++ b/backend/src/processing/parser/xmltv.rs @@ -1,12 +1,13 @@ use crate::model::{Epg, TVGuide, XmlTag, XmlTagIcon, EPG_ATTRIB_CHANNEL, EPG_ATTRIB_ID, EPG_TAG_CHANNEL, EPG_TAG_DISPLAY_NAME, EPG_TAG_ICON, EPG_TAG_PROGRAMME, EPG_TAG_TV}; -use crate::model::{EpgNamePrefix, EpgSmartMatchConfig, PersistedEpgSource}; +use crate::model::{EpgSmartMatchConfig, PersistedEpgSource}; use crate::processing::processor::epg::EpgIdCache; use crate::utils::compressed_file_reader::CompressedFileReader; -use shared::utils::CONSTANTS; use deunicode::deunicode; use quick_xml::events::{BytesStart, BytesText, Event}; use quick_xml::Reader; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use shared::model::EpgNamePrefix; +use shared::utils::CONSTANTS; use std::borrow::Cow; use std::cmp::min; use std::collections::hash_map::Entry; @@ -52,7 +53,7 @@ fn split_by_first_match<'a>(input: &'a str, delimiters: &[char]) -> (Option<&'a fn name_prefix<'a>(name: &'a str, smart_config: &EpgSmartMatchConfig) -> (&'a str, Option<&'a str>) { if smart_config.name_prefix != EpgNamePrefix::Ignore { - let (prefix, suffix) = split_by_first_match(name, &smart_config.t_name_prefix_separator.clone()); + let (prefix, suffix) = split_by_first_match(name, &smart_config.name_prefix_separator); if prefix.is_some() { return (suffix, prefix); } @@ -73,9 +74,9 @@ pub fn normalize_channel_name(name: &str, normalize_config: &EpgSmartMatchConfig let normalized = deunicode(name.trim()).to_lowercase(); let (channel_name, suffix) = name_prefix(&normalized, normalize_config); // Remove all non-alphanumeric characters (except dashes and underscores). - let cleaned_name = normalize_config.t_normalize_regex.as_ref().unwrap().replace_all(channel_name, ""); + let cleaned_name = normalize_config.normalize_regex.replace_all(channel_name, ""); // Remove terms like resolution - let cleaned_name = normalize_config.t_strip.iter().fold(cleaned_name.to_string(), |acc, term| { + let cleaned_name = normalize_config.strip.iter().fold(cleaned_name.to_string(), |acc, term| { acc.replace(term, "") }); match suffix { @@ -380,7 +381,7 @@ where Ok(Event::Empty(e)) => { handle_tag_start(callback, &mut stack, &e); handle_tag_end(callback, &mut stack); - }, + } Ok(Event::End(_e)) => handle_tag_end(callback, &mut stack), Ok(Event::Text(e)) => handle_text_tag(&mut stack, &e), _ => {} @@ -485,7 +486,7 @@ pub fn flatten_tvguide(tv_guides: &[Epg]) -> Option { #[cfg(test)] mod tests { - use crate::model::{EpgNamePrefix, EpgSmartMatchConfig}; + use crate::model::EpgSmartMatchConfig; use crate::processing::parser::xmltv::normalize_channel_name; #[test] @@ -496,11 +497,11 @@ mod tests { /// ``` /// parse_normalize().unwrap(); /// ``` - fn parse_normalize() -> Result<(), TuliproxError> { - let epg_normalize = EpgSmartMatchConfig::new()?; + fn parse_normalize() { + let epg_normalize_dto = EpgSmartMatchConfigDto {..Default::default()}; + let epg_normalize = EpgSmartMatchConfig::from(epg_normalize_dto); let normalized = normalize_channel_name("Love Nature", &epg_normalize); assert_eq!(normalized, "lovenature".to_string()); - Ok(()) } @@ -533,8 +534,9 @@ mod tests { /// // This will assert that various channel names are normalized as expected. /// ``` fn normalize() { - let mut epg_smart_cfg = EpgSmartMatchConfig { enabled: true, name_prefix: EpgNamePrefix::Suffix(".".to_string()), ..Default::default() }; - let _ = epg_smart_cfg.prepare(); + let mut epg_smart_cfg_dto = EpgSmartMatchConfigDto { enabled: true, name_prefix: EpgNamePrefix::Suffix(".".to_string()), ..Default::default() }; + let _ = epg_smart_cfg_dto.prepare(); + let epg_smart_cfg = EpgSmartMatchConfig::from(epg_smart_cfg_dto); println!("{epg_smart_cfg:?}"); assert_eq!("supersport6.ru", normalize_channel_name("RU: SUPERSPORT 6 ᴿᴬᵂ", &epg_smart_cfg)); assert_eq!("odisea.sat", normalize_channel_name("SAT: ODISEA ᴿᴬᵂ", &epg_smart_cfg)); @@ -544,8 +546,9 @@ mod tests { assert_eq!("odisea.bg", normalize_channel_name("BG | ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg)); } - use shared::error::TuliproxError; use rphonetic::{Encoder, Metaphone}; + use shared::error::TuliproxError; + use shared::model::{EpgNamePrefix, EpgSmartMatchConfigDto}; #[test] /// Demonstrates phonetic encoding (Metaphone) of normalized channel names with various prefixes and suffixes. @@ -560,8 +563,9 @@ mod tests { /// ``` fn test_metaphone() { let metaphone = Metaphone::default(); - let mut epg_smart_cfg = EpgSmartMatchConfig { enabled: true, name_prefix: EpgNamePrefix::Suffix(".".to_string()), ..Default::default() }; - let _ = epg_smart_cfg.prepare(); + let mut epg_smart_cfg_dto = EpgSmartMatchConfigDto { enabled: true, name_prefix: EpgNamePrefix::Suffix(".".to_string()), ..Default::default() }; + let _ = epg_smart_cfg_dto.prepare(); + let epg_smart_cfg = EpgSmartMatchConfig::from(epg_smart_cfg_dto); println!("{epg_smart_cfg:?}"); // assert_eq!("supersport6.ru", metaphone.encode(&normalize_channel_name("RU: SUPERSPORT 6 ᴿᴬᵂ", &epg_normalize_cfg))); // assert_eq!("odisea.sat", metaphone.encode(&normalize_channel_name("SAT: ODISEA ᴿᴬᵂ", &epg_normalize_cfg))); diff --git a/backend/src/processing/parser/xtream.rs b/backend/src/processing/parser/xtream.rs index 983943c9d..1da8ba607 100644 --- a/backend/src/processing/parser/xtream.rs +++ b/backend/src/processing/parser/xtream.rs @@ -1,13 +1,12 @@ use shared::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind}; use crate::model::ConfigInput; -use crate::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader}; +use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster}; use crate::model::{XtreamCategory, XtreamSeriesInfo, XtreamSeriesInfoEpisode, XtreamStream}; -use crate::utils::generate_playlist_uuid; +use shared::utils::generate_playlist_uuid; use crate::utils::xtream::{get_xtream_stream_url_base}; use serde_json::Value; use std::collections::HashMap; use std::sync::Arc; -use shared::model::{PlaylistItemType, XtreamCluster}; fn map_to_xtream_category(categories: &Value) -> Result, TuliproxError> { match serde_json::from_value::>(categories.to_owned()) { diff --git a/backend/src/processing/playlist_watch.rs b/backend/src/processing/playlist_watch.rs index 11f23e021..6ef0a6a90 100644 --- a/backend/src/processing/playlist_watch.rs +++ b/backend/src/processing/playlist_watch.rs @@ -2,10 +2,9 @@ use std::collections::BTreeSet; use std::path::{Path}; use std::sync::Arc; use log::{error, info}; -use shared::model::MsgKind; +use shared::model::{MsgKind, PlaylistGroup}; use crate::messaging::{send_message}; use crate::model::Config; -use crate::model::PlaylistGroup; use crate::utils; use crate::utils::{bincode_deserialize, bincode_serialize}; diff --git a/backend/src/processing/processor/affix.rs b/backend/src/processing/processor/affix.rs deleted file mode 100644 index 54d6b986e..000000000 --- a/backend/src/processing/processor/affix.rs +++ /dev/null @@ -1,59 +0,0 @@ -use crate::model::{ConfigInput, InputAffix, valid_property, MAPPER_FIELDS}; -use crate::model::{FetchedPlaylist, FieldGetAccessor, FieldSetAccessor, PlaylistItem}; -use crate::utils::{debug_if_enabled}; - -type AffixProcessor<'a> = Box; - -fn create_affix_processor(affix: &InputAffix, is_prefix: bool) -> AffixProcessor { - Box::new(move |channel: &mut PlaylistItem| { - let header = &mut channel.header; - let value = header.get_field(affix.field.as_str()).map_or_else(|| String::from(&affix.value), |field_value| if is_prefix { - format!("{}{field_value}", &affix.value, ) - } else { - format!("{field_value}{}", , &affix.value) - }); - debug_if_enabled!("Applying input {}: {}={}", if is_prefix {"prefix"} else {"suffix"}, &affix.field, &value); - header.set_field(&affix.field, value.as_str()); - }) -} - -fn validate_and_create_affix_processor(affix: Option<&InputAffix>, is_prefix: bool) -> Option { - if let Some(affix_def) = affix { - if (valid_property!(&affix_def.field.as_str(), MAPPER_FIELDS) && !affix_def.value.is_empty()) { - return Some(create_affix_processor(affix_def, is_prefix)); - } - } - None -} - -fn get_affix_processor(input: &ConfigInput) -> Option { - if input.suffix.is_some() || input.prefix.is_some() { - let processors: Vec = vec![ - validate_and_create_affix_processor(input.prefix.as_ref(), true), - validate_and_create_affix_processor(input.suffix.as_ref(), false) - ].into_iter().flatten().collect(); - - if !processors.is_empty() { - let apply_affix: AffixProcessor = Box::new(move |channel: &mut PlaylistItem| { - for x in &processors { - x(channel); - } - }); - return Some(apply_affix); - } - } - None -} - -pub fn apply_affixes(fetched_playlists: &mut [FetchedPlaylist]) { - for fetched_playlist in fetched_playlists.iter_mut() { - let FetchedPlaylist { input, playlistgroups: playlist, epg: _ } = fetched_playlist; - if let Some(affix_processor) = get_affix_processor(input) { - for group in playlist.iter_mut() { - group.channels.iter_mut().for_each(|channel| { - affix_processor(channel); - }); - } - } - } -} diff --git a/backend/src/processing/processor/epg.rs b/backend/src/processing/processor/epg.rs index 5509bd9fb..3a5ff0226 100644 --- a/backend/src/processing/processor/epg.rs +++ b/backend/src/processing/processor/epg.rs @@ -1,12 +1,12 @@ use crate::model::{Epg, TVGuide, XmlTag, XmlTagIcon, EPG_ATTRIB_ID}; use crate::model::{EpgConfig, EpgSmartMatchConfig}; -use crate::model::{FetchedPlaylist, PlaylistItem}; +use crate::model::{FetchedPlaylist}; use crate::processing::parser::xmltv::normalize_channel_name; use log::{debug, trace}; use rphonetic::{DoubleMetaphone, Encoder}; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; -use shared::model::XtreamCluster; +use shared::model::{EpgSmartMatchConfigDto, PlaylistItem, XtreamCluster}; pub struct EpgIdCache<'a> { pub channel_epg_id: HashSet>, @@ -31,7 +31,10 @@ impl EpgIdCache<'_> { /// assert!(cache.is_empty()); /// ``` pub fn new(epg_config: Option<&EpgConfig>) -> Self { - let normalize_config = epg_config.map_or_else(EpgSmartMatchConfig::default, |epg_config| epg_config.t_smart_match.clone()); + let normalize_config: EpgSmartMatchConfig = epg_config + .and_then(|cfg| cfg.smart_match.clone()) + .unwrap_or_else(|| EpgSmartMatchConfigDto::default().into()); + EpgIdCache { channel_epg_id: HashSet::new(), // contains the epg_ids collected from playlist channels normalized: HashMap::new(), diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index 1c2a53794..7ac57b052 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -1,4 +1,4 @@ -use crate::model::{ConfigInput, ConfigRename}; +use crate::model::{AppConfig, ConfigInput, ConfigRename}; use crate::utils::epg; use crate::utils::m3u; use crate::utils::xtream; @@ -9,30 +9,30 @@ use std::sync::Arc; use std::thread; use tokio::sync::Mutex; -use crate::foundation::filter::{get_field_value, set_field_value, ValueProvider, ValueAccessor}; -use crate::messaging::{send_message}; -use crate::model::{ConfigTarget, InputType, ProcessTargets}; -use crate::model::{CounterModifier, Mapping}; -use crate::model::{FetchedPlaylist, PlaylistGroup, PlaylistItem}; -use shared::model::{FieldGetAccessor, FieldSetAccessor, ItemField, MsgKind, PlaylistEntry, ProcessingOrder, UUIDType, XtreamCluster}; +use crate::messaging::send_message; +use crate::model::Epg; +use crate::model::{ConfigTarget, ProcessTargets}; +use crate::model::{Mapping}; +use crate::model::FetchedPlaylist; use crate::model::{InputStats, PlaylistStats, SourceStats, TargetStats}; +use crate::processing::parser::xmltv::flatten_tvguide; use crate::processing::playlist_watch::process_group_watch; -use crate::processing::processor::xtream_series::playlist_resolve_series; +use crate::processing::processor::epg::process_playlist_epg; +use crate::processing::processor::sort::sort_playlist; use crate::processing::processor::trakt::process_trakt_categories_for_target; +use crate::processing::processor::xtream_series::playlist_resolve_series; +use crate::processing::processor::xtream_vod::playlist_resolve_vod; use crate::repository::playlist_repository::persist_playlist; -use shared::error::{get_errors_notify_message, notify_err, TuliproxError, TuliproxErrorKind}; use crate::utils::debug_if_enabled; -use shared::utils::default_as_default; +use crate::utils::StepMeasure; use deunicode::deunicode; use log::{debug, error, info, log_enabled, trace, warn, Level}; -use std::time::Instant; use reqwest::Client; -use crate::model::Epg; -use crate::processing::parser::xmltv::flatten_tvguide; -use crate::processing::processor::epg::process_playlist_epg; -use crate::processing::processor::xtream_vod::playlist_resolve_vod; -use crate::processing::processor::sort::sort_playlist; -use crate::utils::StepMeasure; +use shared::error::{get_errors_notify_message, notify_err, TuliproxError, TuliproxErrorKind}; +use shared::foundation::filter::{get_field_value, set_field_value, ValueAccessor, ValueProvider}; +use shared::model::{CounterModifier, FieldGetAccessor, FieldSetAccessor, InputType, ItemField, MsgKind, PlaylistEntry, PlaylistGroup, PlaylistItem, ProcessingOrder, UUIDType, XtreamCluster}; +use shared::utils::default_as_default; +use std::time::Instant; fn is_valid(pli: &PlaylistItem, target: &ConfigTarget) -> bool { let provider = ValueProvider { pli }; @@ -85,7 +85,7 @@ fn exec_rename(pli: &mut PlaylistItem, rename: Option<&Vec>) { let result = pli; for r in renames { let value = get_field_value(result, r.field); - let cap = r.re.as_ref().unwrap().replace_all(value.as_str(), &r.new_name); + let cap = r.pattern.replace_all(value.as_str(), &r.new_name); if log_enabled!(log::Level::Debug) && *value != cap { debug_if_enabled!("Renamed {}={} to {}", &r.field, value, cap); } @@ -105,7 +105,7 @@ fn rename_playlist(playlist: &mut [PlaylistGroup], target: &ConfigTarget) -> Opt let mut grp = g.clone(); for r in renames { if matches!(r.field, ItemField::Group) { - let cap = r.re.as_ref().unwrap().replace_all(&grp.title, &r.new_name); + let cap = r.pattern.replace_all(&grp.title, &r.new_name); debug_if_enabled!("Renamed group {} to {} for {}", &grp.title, cap, target.name); grp.title = cap.into_owned(); } @@ -147,7 +147,7 @@ fn map_channel(mut channel: PlaylistItem, mapping: &Mapping) -> PlaylistItem { } fn map_playlist(playlist: &mut [PlaylistGroup], target: &ConfigTarget) -> Option> { - if let Some(mappings) = target.t_mapping.load().as_ref() { + if let Some(mappings) = target.mapping.load().as_ref() { let new_playlist: Vec = playlist.iter().map(|playlist_group| { let mut grp = playlist_group.clone(); mappings.iter().filter(|&mapping| mapping.mapper.as_ref().is_some_and(|v| !v.is_empty())) @@ -184,10 +184,9 @@ fn map_playlist(playlist: &mut [PlaylistGroup], target: &ConfigTarget) -> Option } fn map_playlist_counter(target: &ConfigTarget, playlist: &mut [PlaylistGroup]) { - if target.t_mapping.load().is_some() { - let guard = target.t_mapping.load(); - let mappings = guard.as_ref().unwrap(); - for mapping in mappings.iter() { + if let Some(guard) = &*target.mapping.load() { + let mappings = guard.as_ref(); + for mapping in mappings { if let Some(counter_list) = &mapping.t_counter { for counter in counter_list { for plg in &mut *playlist { @@ -233,8 +232,9 @@ fn is_target_enabled(target: &ConfigTarget, user_targets: &ProcessTargets) -> bo (!user_targets.enabled && target.enabled) || (user_targets.enabled && user_targets.has_target(target.id)) } -async fn process_source(client: Arc, cfg: Arc, source_idx: usize, user_targets: Arc) -> (Vec, Vec, Vec) { - let source = cfg.sources.get_source_at(source_idx).unwrap(); +async fn process_source(client: Arc, cfg: Arc, source_idx: usize, user_targets: Arc) -> (Vec, Vec, Vec) { + let sources = cfg.sources.load(); + let source = sources.get_source_at(source_idx).unwrap(); let mut errors = vec![]; let mut input_stats = HashMap::::new(); let mut target_stats = Vec::::new(); @@ -243,15 +243,18 @@ async fn process_source(client: Arc, cfg: Arc, source_i let mut source_downloaded = false; for input in &source.inputs { if is_input_enabled(input, &user_targets) { + let config = cfg.config.load(); + let working_dir = &config.working_dir; + source_downloaded = true; let start_time = Instant::now(); let (mut playlistgroups, mut error_list) = match input.input_type { - InputType::M3u => m3u::get_m3u_playlist(Arc::clone(&client), &cfg, input, &cfg.working_dir).await, - InputType::Xtream => xtream::get_xtream_playlist(&cfg, Arc::clone(&client), input, &cfg.working_dir).await, + InputType::M3u => m3u::get_m3u_playlist(Arc::clone(&client), &config, input, working_dir).await, + InputType::Xtream => xtream::get_xtream_playlist(&config, Arc::clone(&client), input, working_dir).await, InputType::M3uBatch | InputType::XtreamBatch => (vec![], vec![]) }; let (tvguide, mut tvguide_errors) = if error_list.is_empty() { - epg::get_xmltv(Arc::clone(&client), &cfg, input, &cfg.working_dir).await + epg::get_xmltv(Arc::clone(&client), input, working_dir).await } else { (None, vec![]) }; @@ -288,7 +291,7 @@ async fn process_source(client: Arc, cfg: Arc, source_i debug_if_enabled!("Source has {} groups", source_playlists.iter().map(|fpl| fpl.playlistgroups.len()).sum::()); for target in &source.targets { if is_target_enabled(target, &user_targets) { - match process_playlist_for_target(Arc::clone(&client), &mut source_playlists, target, &cfg, &mut input_stats, &mut errors).await { + match process_playlist_for_target(&cfg, Arc::clone(&client), &mut source_playlists, target, &mut input_stats, &mut errors).await { Ok(()) => { target_stats.push(TargetStats::success(&target.name)); } @@ -321,16 +324,17 @@ fn create_input_stat(group_count: usize, channel_count: usize, error_count: usiz } } -async fn process_sources(client: Arc, config: Arc, user_targets: Arc) -> (Vec, Vec) { +async fn process_sources(client: Arc, config: &Arc, user_targets: Arc) -> (Vec, Vec) { let mut handle_list = vec![]; - let thread_num = config.threads; - let process_parallel = thread_num > 1 && config.sources.sources.len() > 1; + let thread_num = config.config.load().threads; + let sources = config.sources.load(); + let process_parallel = thread_num > 1 && sources.sources.len() > 1; if process_parallel && log_enabled!(Level::Debug) { debug!("Using {thread_num} threads"); } let errors = Arc::new(Mutex::>::new(vec![])); let stats = Arc::new(Mutex::>::new(vec![])); - for (index, _) in config.sources.sources.iter().enumerate() { + for (index, _) in sources.sources.iter().enumerate() { // We're using the file lock this way on purpose let source_lock_path = PathBuf::from(format!("source_{index}")); let Ok(update_lock) = config.file_locks.try_write_lock(&source_lock_path).await else { @@ -433,10 +437,10 @@ fn flatten_groups(playlistgroups: Vec) -> Vec { sort_order } -async fn process_playlist_for_target(client: Arc, +async fn process_playlist_for_target(app_config: &AppConfig, + client: Arc, playlists: &mut [FetchedPlaylist<'_>], target: &ConfigTarget, - cfg: &Config, stats: &mut HashMap, errors: &mut Vec) -> Result<(), Vec> { let pipe = get_processing_pipe(target); @@ -450,8 +454,8 @@ async fn process_playlist_for_target(client: Arc, let mut step = StepMeasure::new("Pipes processed"); for provider_fpl in playlists.iter_mut() { let mut processed_fpl = execute_pipe(target, &pipe, provider_fpl, &mut duplicates); - playlist_resolve_series(Arc::clone(&client), cfg, target, errors, &pipe, provider_fpl, &mut processed_fpl).await; - playlist_resolve_vod(Arc::clone(&client), cfg, target, errors, &mut processed_fpl).await; + playlist_resolve_series(app_config, Arc::clone(&client), target, errors, &pipe, provider_fpl, &mut processed_fpl).await; + playlist_resolve_vod(app_config, Arc::clone(&client), target, errors, &mut processed_fpl).await; // stats let input_stats = stats.get_mut(&processed_fpl.input.name); if let Some(stat) = input_stats { @@ -486,9 +490,10 @@ async fn process_playlist_for_target(client: Arc, map_playlist_counter(target, &mut flat_new_playlist); step.tick("Processed group watches"); - process_watch(&client, target, cfg, &flat_new_playlist); + let config = app_config.config.load(); + process_watch(&config, &client, target, &flat_new_playlist); step.tick("Persisting playlists"); - let result = persist_playlist(&mut flat_new_playlist, flatten_tvguide(&new_epg).as_ref(), target, cfg).await; + let result = persist_playlist(app_config, &mut flat_new_playlist, flatten_tvguide(&new_epg).as_ref(), target).await; step.stop(); result } @@ -522,14 +527,13 @@ fn process_epg(processed_fetched_playlists: &mut Vec) -> (Vec, target: &ConfigTarget, cfg: &Config, new_playlist: &Vec) { - if target.t_watch_re.is_some() { +fn process_watch(cfg: &Config, client: &Arc, target: &ConfigTarget, new_playlist: &Vec) { + if let Some(watches) = &target.watch { if default_as_default().eq_ignore_ascii_case(&target.name) { error!("cant watch a target with no unique name"); } else { - let watch_re = target.t_watch_re.as_ref().unwrap(); for pl in new_playlist { - if watch_re.iter().any(|r| r.is_match(&pl.title)) { + if watches.iter().any(|r| r.is_match(&pl.title)) { process_group_watch(client, cfg, &target.name, pl); } } @@ -537,23 +541,25 @@ fn process_watch(client: &Arc, target: &ConfigTarget, cfg: &Con } } -pub async fn exec_processing(client: Arc, cfg: Arc, targets: Arc) { +pub async fn exec_processing(client: Arc, cfg: Arc, targets: Arc) { let start_time = Instant::now(); - let (stats, errors) = process_sources(Arc::clone(&client), cfg.clone(), targets.clone()).await; + let (stats, errors) = process_sources(Arc::clone(&client), &cfg, targets.clone()).await; // log errors for err in &errors { error!("{}", err.message); } + let config = cfg.config.load(); + let messaging = config.messaging.as_ref(); if let Ok(stats_msg) = serde_json::to_string(&serde_json::Value::Object(serde_json::map::Map::from_iter([("stats".to_string(), serde_json::to_value(stats).unwrap())]))) { // print stats info!("{stats_msg}"); // send stats - send_message(&client, &MsgKind::Stats, cfg.messaging.as_ref(), stats_msg.as_str()); + send_message(&client, &MsgKind::Stats, messaging, stats_msg.as_str()); } // send errors if let Some(message) = get_errors_notify_message!(errors, 255) { if let Ok(error_msg) = serde_json::to_string(&serde_json::Value::Object(serde_json::map::Map::from_iter([("errors".to_string(), serde_json::Value::String(message))]))) { - send_message(&client, &MsgKind::Error, cfg.messaging.as_ref(), error_msg.as_str()); + send_message(&client, &MsgKind::Error, messaging, error_msg.as_str()); } } let elapsed = start_time.elapsed().as_secs(); diff --git a/backend/src/processing/processor/sort.rs b/backend/src/processing/processor/sort.rs index 4b0a28155..f6a9a26e1 100644 --- a/backend/src/processing/processor/sort.rs +++ b/backend/src/processing/processor/sort.rs @@ -1,8 +1,8 @@ -use crate::foundation::filter::get_field_value; -use crate::model::{ConfigSortChannel, ConfigSortGroup, ConfigTarget, SortOrder}; -use crate::model::{PlaylistGroup, PlaylistItem}; +use crate::model::{ConfigSortChannel, ConfigSortGroup, ConfigTarget}; use deunicode::deunicode; use std::cmp::Ordering; +use shared::foundation::filter::get_field_value; +use shared::model::{PlaylistGroup, PlaylistItem, SortOrder}; fn playlist_comparator( sequence: Option<&Vec>, @@ -98,7 +98,7 @@ fn playlistgroup_comparator(a: &PlaylistGroup, b: &PlaylistGroup, group_sort: &C let value_a = if match_as_ascii { deunicode(&a.title) } else { a.title.to_string() }; let value_b = if match_as_ascii { deunicode(&b.title) } else { b.title.to_string() }; - playlist_comparator(group_sort.t_re_sequence.as_ref(), group_sort.order, &value_a, &value_b) + playlist_comparator(group_sort.sequence.as_ref(), group_sort.order, &value_a, &value_b) } fn playlistitem_comparator( @@ -112,7 +112,7 @@ fn playlistitem_comparator( let value_a = if match_as_ascii { deunicode(&raw_value_a) } else { raw_value_a }; let value_b = if match_as_ascii { deunicode(&raw_value_b) } else { raw_value_b }; - playlist_comparator(channel_sort.t_re_sequence.as_ref(), channel_sort.order, &value_a, &value_b) + playlist_comparator(channel_sort.sequence.as_ref(), channel_sort.order, &value_a, &value_b) } pub(in crate::processing::processor) fn sort_playlist(target: &ConfigTarget, new_playlist: &mut [PlaylistGroup]) { @@ -123,7 +123,7 @@ pub(in crate::processing::processor) fn sort_playlist(target: &ConfigTarget, new } if let Some(channel_sorts) = &sort.channels { for channel_sort in channel_sorts { - let regexp = channel_sort.t_re_group_pattern.as_ref().unwrap(); + let regexp = &channel_sort.group_pattern; for group in new_playlist.iter_mut() { let group_title = if match_as_ascii { deunicode(&group.title) } else { group.title.to_string() }; if regexp.is_match(group_title.as_str()) { @@ -137,10 +137,10 @@ pub(in crate::processing::processor) fn sort_playlist(target: &ConfigTarget, new #[cfg(test)] mod tests { - use crate::model::{ConfigSortChannel, ItemField, SortOrder}; - use crate::model::{PlaylistItem, PlaylistItemHeader}; use crate::processing::processor::sort::playlistitem_comparator; use regex::Regex; + use shared::model::{ItemField, PlaylistItem, PlaylistItemHeader, SortOrder}; + use crate::model::ConfigSortChannel; #[test] fn test_sort() { @@ -151,15 +151,13 @@ mod tests { let channel_sort = ConfigSortChannel { field: ItemField::Caption, - group_pattern: ".*".to_string(), order: SortOrder::Asc, - sequence: None, - t_re_sequence: Some(vec![ + sequence: Some(vec![ Regex::new(r"(?P.*?)\bUHD\b").unwrap(), Regex::new(r"(?P.*?)\bFHD\b").unwrap(), Regex::new(r"(?P.*?)\bHD\b").unwrap(), ]), - t_re_group_pattern: Some(Regex::new(".*").unwrap()), + group_pattern: Regex::new(".*").unwrap(), }; channels.sort_by(|chan1, chan2| playlistitem_comparator(chan1, chan2, &channel_sort, true)); @@ -199,10 +197,8 @@ mod tests { let channel_sort = ConfigSortChannel { field: ItemField::Caption, - group_pattern: ".*US.*".to_string(), order: SortOrder::Asc, - sequence: None, - t_re_sequence: Some(vec![ + sequence: Some(vec![ Regex::new(r"^US\| EAST.*?\[\bUHD\b\](?P.*)").unwrap(), Regex::new(r"^US\| EAST.*?\[\bFHD\b\](?P.*)").unwrap(), Regex::new(r"^US\| EAST.*?\[\bHD\b\](?P.*)").unwrap(), @@ -212,7 +208,7 @@ mod tests { Regex::new(r"^US\| WEST.*?\[\bHD\b\](?P.*)").unwrap(), Regex::new(r"^US\| WEST.*?\[\bSD\b\](?P.*)").unwrap(), ]), - t_re_group_pattern: Some(Regex::new(".*").unwrap()), + group_pattern: Regex::new(".*").unwrap(), }; channels.sort_by(|chan1, chan2| playlistitem_comparator(chan1, chan2, &channel_sort, true)); diff --git a/backend/src/processing/processor/trakt.rs b/backend/src/processing/processor/trakt.rs index 4f180726a..16f694835 100644 --- a/backend/src/processing/processor/trakt.rs +++ b/backend/src/processing/processor/trakt.rs @@ -1,10 +1,10 @@ use std::borrow::Cow; -use crate::model::{ConfigTarget, PlaylistGroup, PlaylistItem}; -use shared::model::{FieldGetAccessor, FieldSetAccessor, XtreamCluster}; -use crate::model::{TraktConfig, TraktContentType, TraktListConfig, TraktListItem, TraktMatchItem, TraktMatchResult}; +use crate::model::{ConfigTarget, TraktListItem, TraktMatchItem}; +use shared::model::{FieldGetAccessor, FieldSetAccessor, PlaylistGroup, PlaylistItem, TraktContentType, XtreamCluster}; +use crate::model::{TraktConfig, TraktListConfig, TraktMatchResult}; use shared::error::TuliproxError; use crate::utils::{TraktClient, extract_year_from_title, normalize_title_for_matching}; -use crate::utils::{get_u32_from_serde_value}; +use shared::utils::{get_u32_from_serde_value}; use shared::utils::{CONSTANTS}; use crate::utils::{trace_if_enabled, with}; use log::{debug, info, trace, warn}; @@ -22,7 +22,7 @@ fn extract_quality(value: &str) -> Option<&str> { /// Utility functions for content type compatibility -fn should_include_item(item: &TraktListItem, content_type: &TraktContentType) -> bool { +fn should_include_item(item: &TraktListItem, content_type: TraktContentType) -> bool { match content_type { TraktContentType::Vod => item.content_type == TraktContentType::Vod, TraktContentType::Series => item.content_type == TraktContentType::Series, @@ -30,7 +30,7 @@ fn should_include_item(item: &TraktListItem, content_type: &TraktContentType) -> } } -fn is_compatible_content_type(cluster: XtreamCluster, content_type: &TraktContentType) -> bool { +fn is_compatible_content_type(cluster: XtreamCluster, content_type: TraktContentType) -> bool { match content_type { TraktContentType::Vod => cluster == XtreamCluster::Video, TraktContentType::Series => cluster == XtreamCluster::Series, @@ -216,7 +216,7 @@ fn match_trakt_items_with_playlist<'a>( ) -> Option { let trakt_match_items: Vec> = trakt_items .iter() - .filter(|item| should_include_item(item, &list_config.content_type)) + .filter(|item| should_include_item(item, list_config.content_type)) .filter_map(TraktMatchItem::from_trakt_list_item) .collect(); @@ -225,7 +225,7 @@ fn match_trakt_items_with_playlist<'a>( let mut matches = Vec::new(); for playlist_group in playlist { for channel in &playlist_group.channels { - if is_compatible_content_type(channel.header.xtream_cluster, &list_config.content_type) { + if is_compatible_content_type(channel.header.xtream_cluster, list_config.content_type) { let normalized_title = normalize_title_for_matching(&channel.header.title); let channel_year = extract_year_from_title(&channel.header.title); let channel_tmdb_id = extract_tmdb_id_from_playlist_item(channel); @@ -314,7 +314,7 @@ mod tests { #[test] pub fn test_quality() { let quality = extract_quality("Hello HD UHD 720p"); - assert_eq!(true, quality.is_some()); + assert!(quality.is_some()); assert_eq!("UHD", quality.unwrap()); } } \ No newline at end of file diff --git a/backend/src/processing/processor/xtream.rs b/backend/src/processing/processor/xtream.rs index adcd8cb22..e385ee312 100644 --- a/backend/src/processing/processor/xtream.rs +++ b/backend/src/processing/processor/xtream.rs @@ -1,8 +1,8 @@ use shared::error::{info_err, notify_err}; use shared::error::{str_to_io_error, TuliproxError, TuliproxErrorKind}; -use crate::model::{Config, ConfigInput}; -use crate::model::{FetchedPlaylist, PlaylistItem}; -use shared::model::{PlaylistEntry, PlaylistItemType, XtreamCluster}; +use crate::model::{AppConfig, Config, ConfigInput}; +use crate::model::{FetchedPlaylist}; +use shared::model::{PlaylistEntry, PlaylistItem, PlaylistItemType, XtreamCluster}; use crate::model::normalize_release_date; use crate::repository::storage::get_input_storage_path; use serde::{Deserialize, Serialize}; @@ -87,7 +87,7 @@ pub(in crate::processing) fn should_update_info(pli: &mut PlaylistItem, processe || *old_timestamp.unwrap() != last_modified.unwrap(), provider_id, last_modified.unwrap_or(0)) } -pub(in crate::processing) async fn read_processed_info_ids(cfg: &Config, errors: &mut Vec, fpl: &FetchedPlaylist<'_>, +pub(in crate::processing) async fn read_processed_info_ids(cfg: &AppConfig, errors: &mut Vec, fpl: &FetchedPlaylist<'_>, item_type: PlaylistItemType, extract_ts: F) -> HashMap where F: Fn(&V) -> u64, @@ -96,7 +96,7 @@ where let mut processed_info_ids = HashMap::new(); let fpl_name = &fpl.input.name; - let file_path = match get_input_storage_path(fpl_name, &cfg.working_dir) + let file_path = match get_input_storage_path(fpl_name, &cfg.config.load().working_dir) .map(|storage_path| xtream_get_record_file_path(&storage_path, item_type)).and_then(|opt| opt.ok_or_else(|| str_to_io_error("Not supported"))) { Ok(file_path) => file_path, diff --git a/backend/src/processing/processor/xtream_series.rs b/backend/src/processing/processor/xtream_series.rs index 399851ac1..b3250c88e 100644 --- a/backend/src/processing/processor/xtream_series.rs +++ b/backend/src/processing/processor/xtream_series.rs @@ -1,7 +1,8 @@ +use shared::model::InputType; use shared::error::{TuliproxError, TuliproxErrorKind}; -use crate::model::{Config, ConfigTarget, InputType}; -use crate::model::{FetchedPlaylist, PlaylistGroup, PlaylistItem}; -use shared::model::{PlaylistItemType, XtreamCluster}; +use crate::model::{AppConfig, ConfigTarget}; +use crate::model::{FetchedPlaylist}; +use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemType, XtreamCluster}; use crate::processing::processor::playlist::ProcessingPipe; use crate::processing::parser::xtream::parse_xtream_series_info; use crate::processing::processor::xtream::{create_resolve_episode_wal_files, create_resolve_info_wal_files, playlist_resolve_download_playlist_item, read_processed_info_ids, should_update_info}; @@ -23,7 +24,7 @@ use crate::processing::processor::xtream::normalize_json_content; create_resolve_options_function_for_xtream_target!(series); -async fn read_processed_series_info_ids(cfg: &Config, errors: &mut Vec, fpl: &FetchedPlaylist<'_>) -> HashMap { +async fn read_processed_series_info_ids(cfg: &AppConfig, errors: &mut Vec, fpl: &FetchedPlaylist<'_>) -> HashMap { read_processed_info_ids(cfg, errors, fpl, PlaylistItemType::SeriesInfo, |ts: &u64| *ts).await } @@ -46,13 +47,13 @@ fn should_update_series_info(pli: &mut PlaylistItem, processed_provider_ids: &Ha should_update_info(pli, processed_provider_ids, crate::model::XC_TAG_SERIES_INFO_LAST_MODIFIED) } -async fn playlist_resolve_series_info(client: Arc, cfg: &Config, errors: &mut Vec, +async fn playlist_resolve_series_info(cfg: &AppConfig, client: Arc, errors: &mut Vec, fpl: &mut FetchedPlaylist<'_>, resolve_delay: u16) -> bool { let mut processed_info_ids = read_processed_series_info_ids(cfg, errors, fpl).await; // we cant write to the indexed-document directly because of the write lock and time-consuming operation. // All readers would be waiting for the lock and the app would be unresponsive. // We collect the content into a wal file and write it once we collected everything. - let Some((wal_content_file, wal_record_file, wal_content_path, wal_record_path)) = create_resolve_info_wal_files(cfg, fpl.input, XtreamCluster::Series) + let Some((wal_content_file, wal_record_file, wal_content_path, wal_record_path)) = create_resolve_info_wal_files(&cfg.config.load(), fpl.input, XtreamCluster::Series) else { return !processed_info_ids.is_empty(); }; let mut content_writer = utils::file_writer(&wal_content_file); @@ -124,26 +125,26 @@ async fn playlist_resolve_series_info(client: Arc, cfg: &Config !processed_info_ids.is_empty() } async fn process_series_info( - cfg: &Config, + app_config: &AppConfig, fpl: &mut FetchedPlaylist<'_>, errors: &mut Vec, ) -> Vec { let mut result: Vec = vec![]; let input = fpl.input; - - let Ok(Some((info_path, idx_path))) = get_input_storage_path(&input.name, &cfg.working_dir) + let config = app_config.config.load(); + let Ok(Some((info_path, idx_path))) = get_input_storage_path(&input.name, &config.working_dir) .map(|storage_path| xtream_get_info_file_paths(&storage_path, XtreamCluster::Series)) else { errors.push(notify_err!("Failed to open input info file for series".to_string())); return result; }; - let _file_lock = cfg.file_locks.read_lock(&info_path); + let _file_lock = app_config.file_locks.read_lock(&info_path); // Contains the Series Info with episode listing let Ok(mut info_reader) = IndexedDocumentReader::::new(&info_path, &idx_path) else { return result; }; - let Some((wal_file, wal_path)) = create_resolve_episode_wal_files(cfg, input) else { + let Some((wal_file, wal_path)) = create_resolve_episode_wal_files(&config, input) else { errors.push(notify_err!("Could not create wal file for series episodes record".to_string())); return result; }; @@ -201,13 +202,15 @@ async fn process_series_info( |err| errors.push(notify_err!(format!("Failed to resolve series episodes, could not write to wal file {err}")))); drop(wal_writer); drop(wal_file); - handle_error!(xtream_update_input_series_episodes_record_from_wal_file(cfg, input, &wal_path).await, + handle_error!(xtream_update_input_series_episodes_record_from_wal_file(app_config, input, &wal_path).await, |err| errors.push(err)); result } -pub async fn playlist_resolve_series(client: Arc, cfg: &Config, target: &ConfigTarget, +pub async fn playlist_resolve_series(cfg: &AppConfig, + client: Arc, + target: &ConfigTarget, errors: &mut Vec, pipe: &ProcessingPipe, provider_fpl: &mut FetchedPlaylist<'_>, @@ -216,7 +219,7 @@ pub async fn playlist_resolve_series(client: Arc, cfg: &Config, let (resolve_series, resolve_delay) = get_resolve_series_options(target, processed_fpl); if !resolve_series { return; } - if !playlist_resolve_series_info(client, cfg, errors, processed_fpl, resolve_delay).await { return; } + if !playlist_resolve_series_info(cfg, client, errors, processed_fpl, resolve_delay).await { return; } let series_playlist = process_series_info(cfg, provider_fpl, errors).await; if series_playlist.is_empty() { return; } // original content saved into original list diff --git a/backend/src/processing/processor/xtream_vod.rs b/backend/src/processing/processor/xtream_vod.rs index a4ab90241..5f87d63f6 100644 --- a/backend/src/processing/processor/xtream_vod.rs +++ b/backend/src/processing/processor/xtream_vod.rs @@ -1,12 +1,13 @@ +use shared::model::InputType; use shared::error::{TuliproxError, TuliproxErrorKind}; -use crate::model::{Config, ConfigTarget, InputType}; -use crate::model::{FetchedPlaylist, PlaylistItem}; -use shared::model::{PlaylistItemType, XtreamCluster}; +use crate::model::{AppConfig, ConfigTarget}; +use crate::model::{FetchedPlaylist}; +use shared::model::{PlaylistItem, PlaylistItemType, XtreamCluster}; use crate::processing::processor::xtream::{create_resolve_info_wal_files, playlist_resolve_download_playlist_item, read_processed_info_ids, should_update_info}; use crate::repository::xtream_repository::{write_vod_info_to_wal_file, xtream_update_input_info_file, xtream_update_input_vod_record_from_wal_file, InputVodInfoRecord}; use shared::error::{notify_err}; use crate::processing::processor::{handle_error, handle_error_and_return, create_resolve_options_function_for_xtream_target}; -use crate::utils::{get_u32_from_serde_value, get_u64_from_serde_value, get_string_from_serde_value}; +use shared::utils::{get_u32_from_serde_value, get_u64_from_serde_value, get_string_from_serde_value}; use crate::repository::xtream_repository::xtream_get_input_info; use serde_json::{from_str, Map, Value}; use std::collections::HashMap; @@ -19,7 +20,7 @@ use crate::processing::processor::xtream::normalize_json_content; create_resolve_options_function_for_xtream_target!(vod); -async fn read_processed_vod_info_ids(cfg: &Config, errors: &mut Vec, fpl: &FetchedPlaylist<'_>) -> HashMap { +async fn read_processed_vod_info_ids(cfg: &AppConfig, errors: &mut Vec, fpl: &FetchedPlaylist<'_>) -> HashMap { read_processed_info_ids(cfg, errors, fpl, PlaylistItemType::Video, |record: &InputVodInfoRecord| record.ts).await } @@ -58,17 +59,18 @@ fn should_update_vod_info(pli: &mut PlaylistItem, processed_provider_ids: &HashM should_update_info(pli, processed_provider_ids, crate::model::XC_TAG_VOD_INFO_ADDED) } -pub async fn playlist_resolve_vod(client: Arc, cfg: &Config, target: &ConfigTarget, errors: &mut Vec, fpl: &mut FetchedPlaylist<'_>) { +pub async fn playlist_resolve_vod(app_config: &AppConfig, client: Arc, target: &ConfigTarget, errors: &mut Vec, fpl: &mut FetchedPlaylist<'_>) { let (resolve_movies, resolve_delay) = get_resolve_vod_options(target, fpl); if !resolve_movies { return; } // we cant write to the indexed-document directly because of the write lock and time-consuming operation. // All readers would be waiting for the lock and the app would be unresponsive. // We collect the content into a wal file and write it once we collected everything. - let Some((wal_content_file, wal_record_file, wal_content_path, wal_record_path)) = create_resolve_info_wal_files(cfg, fpl.input, XtreamCluster::Video) + let config = app_config.config.load(); + let Some((wal_content_file, wal_record_file, wal_content_path, wal_record_path)) = create_resolve_info_wal_files(&config, fpl.input, XtreamCluster::Video) else { return; }; - let mut processed_info_ids = read_processed_vod_info_ids(cfg, errors, fpl).await; + let mut processed_info_ids = read_processed_vod_info_ids(app_config, errors, fpl).await; let mut content_writer = utils::file_writer(&wal_content_file); let mut record_writer = utils::file_writer(&wal_record_file); let mut content_updated = false; @@ -126,9 +128,9 @@ pub async fn playlist_resolve_vod(client: Arc, cfg: &Config, ta drop(record_writer); drop(wal_content_file); drop(wal_record_file); - handle_error!(xtream_update_input_info_file(cfg, fpl.input, &wal_content_path, XtreamCluster::Video).await, + handle_error!(xtream_update_input_info_file(app_config, fpl.input, &wal_content_path, XtreamCluster::Video).await, |err| errors.push(err)); - handle_error!(xtream_update_input_vod_record_from_wal_file(cfg, fpl.input, &wal_record_path).await, + handle_error!(xtream_update_input_vod_record_from_wal_file(app_config, fpl.input, &wal_record_path).await, |err| errors.push(err)); } @@ -140,7 +142,7 @@ pub async fn playlist_resolve_vod(client: Arc, cfg: &Config, ta for pli in vod_info_iter { if let Some(provider_id) = pli.header.get_provider_id() { - if let Some(content) = xtream_get_input_info(cfg, fpl.input, provider_id, XtreamCluster::Video) { + if let Some(content) = xtream_get_input_info(app_config, fpl.input, provider_id, XtreamCluster::Video) { pli.header.additional_properties = from_str::>(&content).ok().and_then(|info_doc| info_doc.get("info").cloned()); } } diff --git a/backend/src/repository/bplustree.rs b/backend/src/repository/bplustree.rs index c48e523d4..4c53a76c1 100644 --- a/backend/src/repository/bplustree.rs +++ b/backend/src/repository/bplustree.rs @@ -770,7 +770,7 @@ mod tests { use std::path::PathBuf; use serde::{Deserialize, Serialize}; - + use shared::utils::generate_random_string; use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery, BPlusTreeUpdate}; // Example usage with a simple struct @@ -784,7 +784,7 @@ mod tests { #[test] fn insert_test() -> io::Result<()> { let test_size = 500; - let content = crate::utils::generate_random_string(1024); + let content = generate_random_string(1024); let mut tree = BPlusTree::::new(); for i in 0u32..=test_size { tree.insert(i, Record { diff --git a/backend/src/repository/epg_repository.rs b/backend/src/repository/epg_repository.rs index c3f86d561..eda7cee78 100644 --- a/backend/src/repository/epg_repository.rs +++ b/backend/src/repository/epg_repository.rs @@ -35,7 +35,7 @@ fn epg_write_file(target: &ConfigTarget, epg: &Epg, path: &Path) -> Result<(), T Ok(()) } -pub fn epg_write(target: &ConfigTarget, cfg: &Config, target_path: &Path, epg: Option<&Epg>, output: &TargetOutput) -> Result<(), TuliproxError> { +pub fn epg_write(cfg: &Config, target: &ConfigTarget, target_path: &Path, epg: Option<&Epg>, output: &TargetOutput) -> Result<(), TuliproxError> { if let Some(epg_data) = epg { match output { TargetOutput::Xtream(_) => { diff --git a/backend/src/repository/indexed_document.rs b/backend/src/repository/indexed_document.rs index 7d735345d..ae2c17335 100644 --- a/backend/src/repository/indexed_document.rs +++ b/backend/src/repository/indexed_document.rs @@ -511,7 +511,7 @@ mod tests { use std::path::PathBuf; use serde::{Deserialize, Serialize}; - use crate::model::XtreamPlaylistItem; + use shared::model::XtreamPlaylistItem; // use crate::model::XtreamPlaylistItem; use crate::repository::indexed_document::{IndexedDocumentGarbageCollector, IndexedDocumentIterator, IndexedDocumentWriter}; use crate::utils::resolve_env_var; diff --git a/backend/src/repository/m3u_playlist_iterator.rs b/backend/src/repository/m3u_playlist_iterator.rs index 6fc40951e..8d8c60205 100644 --- a/backend/src/repository/m3u_playlist_iterator.rs +++ b/backend/src/repository/m3u_playlist_iterator.rs @@ -1,9 +1,8 @@ use shared::error::info_err; use shared::error::{TuliproxError, TuliproxErrorKind}; -use crate::model::{ProxyUserCredentials}; -use crate::model::{Config, ConfigTarget, ConfigTargetOptions}; -use crate::model::{M3uPlaylistItem}; -use shared::model::{PlaylistItemType, ProxyType, TargetType, XtreamCluster}; +use crate::model::{AppConfig, ProxyUserCredentials}; +use crate::model::{ConfigTarget}; +use shared::model::{ConfigTargetOptions, M3uPlaylistItem, PlaylistItemType, ProxyType, TargetType, XtreamCluster}; use crate::repository::indexed_document::IndexedDocumentIterator; use crate::repository::m3u_repository::m3u_get_file_paths; use crate::repository::storage::ensure_target_storage_path; @@ -30,12 +29,13 @@ pub struct M3uPlaylistIterator { impl M3uPlaylistIterator { pub async fn new( - cfg: &Config, + cfg: &AppConfig, target: &ConfigTarget, user: &ProxyUserCredentials, ) -> Result { let m3u_output = target.get_m3u_output().ok_or_else(|| info_err!(format!("Unexpected failure, missing m3u target output for target {}", target.name)))?; - let target_path = ensure_target_storage_path(cfg, target.name.as_str())?; + let config = cfg.config.load(); + let target_path = ensure_target_storage_path(&config, target.name.as_str())?; let (m3u_path, idx_path) = m3u_get_file_paths(&target_path); let file_lock = cfg.file_locks.read_lock(&m3u_path).await; @@ -44,7 +44,7 @@ impl M3uPlaylistIterator { IndexedDocumentIterator::::new(&m3u_path, &idx_path) .map_err(|err| info_err!(format!("Could not deserialize file {m3u_path:?} - {err}")))?; - let filter = user_get_bouquet_filter(cfg, &user.username, None, TargetType::M3u, XtreamCluster::Live).await; + let filter = user_get_bouquet_filter(&config, &user.username, None, TargetType::M3u, XtreamCluster::Live).await; let server_info = cfg.get_user_server_info(user); Ok(Self { @@ -56,7 +56,7 @@ impl M3uPlaylistIterator { include_type_in_url: m3u_output.include_type_in_url, mask_redirect_url: m3u_output.mask_redirect_url, filter, - proxy_type: user.proxy.clone(), + proxy_type: user.proxy, _file_lock: file_lock, // Save lock inside struct rewrite_resource: cfg.is_reverse_proxy_resource_rewrite_enabled(), lookup_item: None, @@ -152,7 +152,7 @@ pub struct M3uPlaylistM3uTextIterator { impl M3uPlaylistM3uTextIterator { pub async fn new( - cfg: &Config, + cfg: &AppConfig, target: &ConfigTarget, user: &ProxyUserCredentials, ) -> Result { diff --git a/backend/src/repository/m3u_repository.rs b/backend/src/repository/m3u_repository.rs index 12dbf2d9a..faf67e225 100644 --- a/backend/src/repository/m3u_repository.rs +++ b/backend/src/repository/m3u_repository.rs @@ -1,8 +1,8 @@ use shared::error::{create_tuliprox_error,info_err}; use shared::error::{str_to_io_error, TuliproxError, TuliproxErrorKind}; -use crate::model::ProxyUserCredentials; +use crate::model::{AppConfig, ProxyUserCredentials}; use crate::model::{Config, ConfigTarget, M3uTargetOutput}; -use crate::model::{M3uPlaylistItem, PlaylistGroup, PlaylistItem}; +use shared::model::{M3uPlaylistItem, PlaylistGroup, PlaylistItem}; use crate::repository::indexed_document::{IndexedDocumentDirectAccess, IndexedDocumentIterator, IndexedDocumentWriter}; use crate::repository::m3u_playlist_iterator::{M3uPlaylistM3uTextIterator}; use crate::repository::storage::{get_target_storage_path}; @@ -10,7 +10,6 @@ use log::error; use std::fs::File; use std::io::{Error, Write}; use std::path::{Path, PathBuf}; -use std::sync::Arc; use shared::model::PlaylistItemType; use crate::repository::storage_const; use crate::utils; @@ -52,7 +51,7 @@ fn persist_m3u_playlist_as_text(cfg: &Config, target: &ConfigTarget, target_outp } } -pub async fn m3u_write_playlist( cfg: &Config, target: &ConfigTarget, target_output: &M3uTargetOutput, target_path: &Path, new_playlist: &[PlaylistGroup]) -> Result<(), TuliproxError> { +pub async fn m3u_write_playlist( cfg: &AppConfig, target: &ConfigTarget, target_output: &M3uTargetOutput, target_path: &Path, new_playlist: &[PlaylistGroup]) -> Result<(), TuliproxError> { if !new_playlist.is_empty() { let (m3u_path, idx_path) = m3u_get_file_paths(target_path); let m3u_playlist = new_playlist.iter() @@ -60,7 +59,7 @@ pub async fn m3u_write_playlist( cfg: &Config, target: &ConfigTarget, target_out .filter(|&pli| pli.header.item_type != PlaylistItemType::SeriesInfo) .map(PlaylistItem::to_m3u).collect::>(); - persist_m3u_playlist_as_text(cfg, target, target_output, &m3u_playlist); + persist_m3u_playlist_as_text(&cfg.config.load(), target, target_output, &m3u_playlist); { let _file_lock = cfg.file_locks.write_lock(&m3u_path); match IndexedDocumentWriter::new(m3u_path.clone(), idx_path) { @@ -81,27 +80,27 @@ pub async fn m3u_write_playlist( cfg: &Config, target: &ConfigTarget, target_out } pub async fn m3u_load_rewrite_playlist( - cfg: &Config, + cfg: &AppConfig, target: &ConfigTarget, user: &ProxyUserCredentials, ) -> Result { M3uPlaylistM3uTextIterator::new(cfg, target, user).await } -pub async fn m3u_get_item_for_stream_id(stream_id: u32, cfg: &Config, target: &ConfigTarget) -> Result { +pub async fn m3u_get_item_for_stream_id(stream_id: u32, cfg: &AppConfig, target: &ConfigTarget) -> Result { if stream_id < 1 { return Err(str_to_io_error("id should start with 1")); } { - let target_path = get_target_storage_path(cfg, target.name.as_str()).ok_or_else(|| str_to_io_error(&format!("Could not find path for target {}", &target.name)))?; + let target_path = get_target_storage_path(&cfg.config.load(), target.name.as_str()).ok_or_else(|| str_to_io_error(&format!("Could not find path for target {}", &target.name)))?; let (m3u_path, idx_path) = m3u_get_file_paths(&target_path); let _file_lock = cfg.file_locks.read_lock(&m3u_path); IndexedDocumentDirectAccess::read_indexed_item::(&m3u_path, &idx_path, &stream_id) } } -pub async fn iter_raw_m3u_playlist(config: &Arc, target: &ConfigTarget) -> Option<(utils::FileReadGuard, impl Iterator)> { - let target_path = get_target_storage_path(config, target.name.as_str())?; +pub async fn iter_raw_m3u_playlist(config: &AppConfig, target: &ConfigTarget) -> Option<(utils::FileReadGuard, impl Iterator)> { + let target_path = get_target_storage_path(&config.config.load(), target.name.as_str())?; let (m3u_path, idx_path) = m3u_get_file_paths(&target_path); if !m3u_path.exists() || !idx_path.exists() { return None; diff --git a/backend/src/repository/playlist_repository.rs b/backend/src/repository/playlist_repository.rs index 4fd46cb77..8a618c2d5 100644 --- a/backend/src/repository/playlist_repository.rs +++ b/backend/src/repository/playlist_repository.rs @@ -1,8 +1,7 @@ use shared::error::info_err; use shared::error::{TuliproxError, TuliproxErrorKind}; -use crate::model::{Config, ConfigTarget, TargetOutput}; -use crate::model::{PlaylistGroup}; -use shared::model::{PlaylistItemType}; +use crate::model::{AppConfig, ConfigTarget, TargetOutput}; +use shared::model::{PlaylistGroup, PlaylistItemType}; use crate::model::Epg; use crate::repository::epg_repository::epg_write; use crate::repository::strm_repository::write_strm_playlist; @@ -10,19 +9,20 @@ use crate::repository::m3u_repository::m3u_write_playlist; use crate::repository::storage::{ensure_target_storage_path, get_target_id_mapping_file}; use crate::repository::target_id_mapping::TargetIdMapping; use crate::repository::xtream_repository::xtream_write_playlist; -use crate::utils::request::{is_dash_url, is_hls_url}; use std::path::Path; +use shared::utils::{is_dash_url, is_hls_url}; use crate::utils; -pub async fn persist_playlist(playlist: &mut [PlaylistGroup], epg: Option<&Epg>, - target: &ConfigTarget, cfg: &Config) -> Result<(), Vec> { +pub async fn persist_playlist(app_config: &AppConfig, playlist: &mut [PlaylistGroup], epg: Option<&Epg>, + target: &ConfigTarget) -> Result<(), Vec> { let mut errors = vec![]; - let target_path = match ensure_target_storage_path(cfg, &target.name) { + let config = &app_config.config.load(); + let target_path = match ensure_target_storage_path(config, &target.name) { Ok(path) => path, Err(err) => return Err(vec![err]), }; - let (mut target_id_mapping, file_lock) = get_target_id_mapping(cfg, &target_path).await; + let (mut target_id_mapping, file_lock) = get_target_id_mapping(app_config, &target_path).await; // Virtual IDs assignment for group in playlist.iter_mut() { @@ -50,16 +50,16 @@ pub async fn persist_playlist(playlist: &mut [PlaylistGroup], epg: Option<&Epg>, for output in &target.output { let result = match output { - TargetOutput::Xtream(_xtream_output) => xtream_write_playlist(target, cfg, playlist).await, - TargetOutput::M3u(m3u_output) => m3u_write_playlist(cfg, target, m3u_output, &target_path, playlist).await, - TargetOutput::Strm(strm_output) => write_strm_playlist(target, strm_output, cfg, playlist).await, + TargetOutput::Xtream(_xtream_output) => xtream_write_playlist(app_config, target, playlist).await, + TargetOutput::M3u(m3u_output) => m3u_write_playlist(app_config, target, m3u_output, &target_path, playlist).await, + TargetOutput::Strm(strm_output) => write_strm_playlist(app_config, target, strm_output, playlist).await, TargetOutput::HdHomeRun(_hdhomerun_output) => Ok(()), }; if let Err(err) = result { errors.push(err); } else if !playlist.is_empty() { - if let Err(err) = epg_write(target, cfg, &target_path, epg, output) { + if let Err(err) = epg_write(config, target, &target_path, epg, output) { errors.push(err); } } @@ -73,7 +73,7 @@ pub async fn persist_playlist(playlist: &mut [PlaylistGroup], epg: Option<&Epg>, if errors.is_empty() { Ok(()) } else { Err(errors) } } -pub async fn get_target_id_mapping(cfg: &Config, target_path: &Path) -> (TargetIdMapping, utils::FileWriteGuard) { +pub async fn get_target_id_mapping(cfg: &AppConfig, target_path: &Path) -> (TargetIdMapping, utils::FileWriteGuard) { let target_id_mapping_file = get_target_id_mapping_file(target_path); let file_lock = cfg.file_locks.write_lock(&target_id_mapping_file).await; (TargetIdMapping::new(&target_id_mapping_file), file_lock) diff --git a/backend/src/repository/strm_repository.rs b/backend/src/repository/strm_repository.rs index fb0a31f74..5a7ae7056 100644 --- a/backend/src/repository/strm_repository.rs +++ b/backend/src/repository/strm_repository.rs @@ -1,17 +1,14 @@ use shared::error::{create_tuliprox_error_result, info_err}; use shared::error::{TuliproxError, TuliproxErrorKind}; -use crate::model::{ApiProxyServerInfo, ProxyUserCredentials}; -use crate::model::{Config, ConfigTarget, StrmTargetOutput}; -use crate::model::{PlaylistGroup, PlaylistItem}; +use crate::model::{ApiProxyServerInfo, AppConfig, ProxyUserCredentials}; +use crate::model::{ConfigTarget, StrmTargetOutput}; use crate::model::XtreamSeriesEpisode; use crate::repository::bplustree::BPlusTree; use crate::repository::storage::{ensure_target_storage_path, get_input_storage_path}; use crate::repository::storage_const; use crate::repository::xtream_repository::{xtream_get_record_file_path, InputVodInfoRecord}; -use crate::utils::{hash_bytes}; -use shared::utils::{ExportStyleConfig, CONSTANTS}; +use shared::utils::{extract_extension_from_url, hash_bytes, ExportStyleConfig, CONSTANTS}; use crate::utils::FileReadGuard; -use crate::utils::request::extract_extension_from_url; use chrono::Datelike; use filetime::{set_file_times, FileTime}; use log::{error, trace}; @@ -22,7 +19,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::fs::{create_dir_all, remove_dir, remove_file, File}; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, BufWriter}; -use shared::model::{ClusterFlags, FieldGetAccessor, PlaylistItemType, StrmExportStyle, UUIDType}; +use shared::model::{ClusterFlags, FieldGetAccessor, PlaylistGroup, PlaylistItem, PlaylistItemType, StrmExportStyle, UUIDType}; use crate::utils; /// Sanitizes a string to be safe for use as a file or directory name by @@ -170,7 +167,7 @@ enum InputTmdbIndexValue { type InputTmdbIndexMap = HashMap>; async fn get_tmdb_value( - cfg: &Config, + cfg: &AppConfig, provider_id: Option, input_name: &str, input_indexes: &mut InputTmdbIndexMap, @@ -195,7 +192,7 @@ async fn get_tmdb_value( } } std::collections::hash_map::Entry::Vacant(entry) => { - if let Ok(Some(tmdb_path)) = get_input_storage_path(input_name, &cfg.working_dir) + if let Ok(Some(tmdb_path)) = get_input_storage_path(input_name, &cfg.config.load().working_dir) .map(|storage_path| xtream_get_record_file_path(&storage_path, item_type)) { { @@ -669,7 +666,7 @@ fn format_for_jellyfin( /// Generates style-compliant directory and file names by dispatching /// the call to a dedicated formatting function for the respective style. async fn style_based_rename( - cfg: &Config, + cfg: &AppConfig, strm_item_info: &StrmItemInfo, input_tmdb_indexes: &mut InputTmdbIndexMap, style: &StrmExportStyle, @@ -701,7 +698,7 @@ async fn style_based_rename( } async fn prepare_strm_files( - cfg: &Config, + cfg: &AppConfig, new_playlist: &mut [PlaylistGroup], _root_path: &Path, underscore_whitespace: bool, @@ -786,17 +783,18 @@ async fn prepare_strm_files( } pub async fn write_strm_playlist( + app_config: &AppConfig, target: &ConfigTarget, target_output: &StrmTargetOutput, - cfg: &Config, new_playlist: &mut [PlaylistGroup], ) -> Result<(), TuliproxError> { if new_playlist.is_empty() { return Ok(()); } + let config = app_config.config.load(); let Some(root_path) = utils::get_file_path( - &cfg.working_dir, + &config.working_dir, Some(std::path::PathBuf::from(&target_output.directory)), ) else { return Err(info_err!(format!( @@ -805,11 +803,11 @@ pub async fn write_strm_playlist( ))); }; - let user_and_server_info = get_credentials_and_server_info(cfg, target_output.username.as_ref()); + let user_and_server_info = get_credentials_and_server_info(app_config, target_output.username.as_ref()); let strm_index_path = - strm_get_file_paths(&ensure_target_storage_path(cfg, target.name.as_str())?); + strm_get_file_paths(&ensure_target_storage_path(&config, target.name.as_str())?); let existing_strm = { - let _file_lock = cfg + let _file_lock = app_config .file_locks .read_lock(&strm_index_path); read_strm_file_index(&strm_index_path) @@ -831,7 +829,7 @@ pub async fn write_strm_playlist( // - M3u Series (TODO we dont have this currently, should be guessed through m3u parser) // - M3u Vod (no additional infos, need to extract from title) let strm_files = prepare_strm_files( - cfg, + app_config, new_playlist, &root_path, target_output.underscore_whitespace, @@ -879,7 +877,7 @@ pub async fn write_strm_playlist( }; } - if let Err(err) = write_strm_index_file(cfg, &processed_strm, &strm_index_path).await { + if let Err(err) = write_strm_index_file(app_config, &processed_strm, &strm_index_path).await { failed.push(err); } @@ -896,7 +894,7 @@ pub async fn write_strm_playlist( } } async fn write_strm_index_file( - cfg: &Config, + cfg: &AppConfig, entries: &HashSet, index_file_path: &PathBuf, ) -> Result<(), String> { @@ -981,7 +979,7 @@ async fn has_strm_file_same_hash(file_path: &PathBuf, content_hash: UUIDType) -> } fn get_credentials_and_server_info( - cfg: &Config, + cfg: &AppConfig, username: Option<&String>, ) -> Option<(ProxyUserCredentials, ApiProxyServerInfo)> { let username = username?; diff --git a/backend/src/repository/user_repository.rs b/backend/src/repository/user_repository.rs index 10bc3f095..c086a1571 100644 --- a/backend/src/repository/user_repository.rs +++ b/backend/src/repository/user_repository.rs @@ -1,4 +1,4 @@ -use crate::model::{ProxyUserCredentials, TargetUser}; +use crate::model::{AppConfig, ProxyUserCredentials, TargetUser}; use crate::model::{Config}; use shared::model::{ProxyType, ProxyUserStatus, TargetType, XtreamCluster}; use crate::model::{PlaylistBouquetDto, TargetBouquetDto}; @@ -36,7 +36,7 @@ impl StoredProxyUserCredentialsDeprecated { username: stored.username.clone(), password: stored.password.clone(), token: stored.token.clone(), - proxy: stored.proxy.clone(), + proxy: stored.proxy, server: stored.server.clone(), epg_timeshift: stored.epg_timeshift.clone(), created_at: stored.created_at, @@ -77,7 +77,7 @@ impl StoredProxyUserCredentials { username: proxy.username.clone(), password: proxy.password.clone(), token: proxy.token.clone(), - proxy: proxy.proxy.clone(), + proxy: proxy.proxy, server: proxy.server.clone(), epg_timeshift: proxy.epg_timeshift.clone(), created_at: proxy.created_at, @@ -94,7 +94,7 @@ impl StoredProxyUserCredentials { username: stored.username.clone(), password: stored.password.clone(), token: stored.token.clone(), - proxy: stored.proxy.clone(), + proxy: stored.proxy, server: stored.server.clone(), epg_timeshift: stored.epg_timeshift.clone(), created_at: stored.created_at, @@ -108,7 +108,7 @@ impl StoredProxyUserCredentials { } -pub fn get_api_user_db_path(cfg: &Config) -> PathBuf { +pub fn get_api_user_db_path(cfg: &AppConfig) -> PathBuf { PathBuf::from(&cfg.t_config_path).join(storage_const::API_USER_DB_FILE) } @@ -122,7 +122,7 @@ fn add_target_user_to_user_tree(target_users: &[TargetUser], user_tree: &mut BPl } } -pub fn merge_api_user(cfg: &Config, target_users: &[TargetUser]) -> Result { +pub fn merge_api_user(cfg: &AppConfig, target_users: &[TargetUser]) -> Result { let path = get_api_user_db_path(cfg); let lock = cfg.file_locks.read_lock(&path); let mut user_tree: BPlusTree = BPlusTree::load(&path).unwrap_or_else(|_| BPlusTree::new()); @@ -135,8 +135,8 @@ pub fn merge_api_user(cfg: &Config, target_users: &[TargetUser]) -> Result Result { +pub fn store_api_user(cfg: &AppConfig, target_users: &[TargetUser]) -> Result { let mut user_tree = BPlusTree::::new(); add_target_user_to_user_tree(target_users, &mut user_tree); let path = get_api_user_db_path(cfg); @@ -156,7 +156,7 @@ pub fn store_api_user(cfg: &Config, target_users: &[TargetUser]) -> Result Result, Error> { +pub fn load_api_user_deprecated(cfg: &AppConfig) -> Result, Error> { let path = get_api_user_db_path(cfg); let lock = cfg.file_locks.read_lock(&path); let user_tree = BPlusTree::::load(&path)?; @@ -182,7 +182,7 @@ pub fn load_api_user_deprecated(cfg: &Config) -> Result, Error> } -pub fn load_api_user(cfg: &Config) -> Result, Error> { +pub fn load_api_user(cfg: &AppConfig) -> Result, Error> { let path = get_api_user_db_path(cfg); let lock = cfg.file_locks.read_lock(&path); let Ok(user_tree) = BPlusTree::::load(&path) else { return load_api_user_deprecated(cfg) }; @@ -397,9 +397,11 @@ pub async fn user_get_bouquet_filter(config: &Config, username: &str, category_i #[cfg(test)] mod tests { use super::*; - use crate::model::{ProxyType, ProxyUserStatus}; + use shared::model::{ProxyType, ProxyUserStatus}; use std::env::temp_dir; - + use std::sync::Arc; + use arc_swap::ArcSwapAny; + use crate::utils::FileLockManager; #[test] pub fn save_target_user() { @@ -466,7 +468,22 @@ mod tests { ], }; - let mut cfg = Config::default(); + let mut cfg = AppConfig { + config: Arc::new(ArcSwapAny::default()), + sources: Arc::new(ArcSwapAny::default()), + t_hdhomerun: Arc::new(ArcSwapAny::default()), + t_api_proxy: Arc::new(ArcSwapAny::default()), + t_config_path: String::new(), + t_config_file_path: String::new(), + t_sources_file_path: String::new(), + t_mapping_file_path: String::new(), + t_api_proxy_file_path: String::new(), + t_custom_stream_response_path: None, + file_locks: Arc::new(FileLockManager::default()), + t_custom_stream_response: None, + t_access_token_secret: Default::default(), + t_encrypt_secret: Default::default(), + }; let target_user = vec![user]; cfg.t_config_path = temp_dir().to_string_lossy().to_string(); let _ = store_api_user(&cfg, &target_user); diff --git a/backend/src/repository/xtream_playlist_iterator.rs b/backend/src/repository/xtream_playlist_iterator.rs index 7fbda0bfc..d8839b9de 100644 --- a/backend/src/repository/xtream_playlist_iterator.rs +++ b/backend/src/repository/xtream_playlist_iterator.rs @@ -1,11 +1,11 @@ use std::collections::HashSet; use log::error; -use shared::model::{TargetType, XtreamCluster}; +use serde_json::Value; +use shared::model::{TargetType, XtreamCluster, XtreamPlaylistItem}; use shared::error::info_err; use shared::error::{TuliproxError, TuliproxErrorKind}; -use crate::model::{ProxyUserCredentials}; -use crate::model::{Config, ConfigTarget}; -use crate::model::{XtreamPlaylistItem}; +use crate::model::{xtream_playlistitem_to_document, AppConfig, ProxyUserCredentials}; +use crate::model::{ConfigTarget}; use crate::model::XtreamMappingOptions; use crate::repository::indexed_document::{IndexedDocumentIterator}; use crate::repository::user_repository::user_get_bouquet_filter; @@ -25,26 +25,27 @@ pub struct XtreamPlaylistIterator { impl XtreamPlaylistIterator { pub async fn new( cluster: XtreamCluster, - config: &Config, + app_config: &AppConfig, target: &ConfigTarget, category_id: Option, user: &ProxyUserCredentials, ) -> Result { let xtream_output = target.get_xtream_output().ok_or_else(|| info_err!(format!("Unexpected: xtream output required for target {}", target.name)))?; - if let Some(storage_path) = xtream_get_storage_path(config, target.name.as_str()) { + let config = app_config.config.load(); + if let Some(storage_path) = xtream_get_storage_path(&config, target.name.as_str()) { let (xtream_path, idx_path) = xtream_get_file_paths(&storage_path, cluster); if !xtream_path.exists() || !idx_path.exists() { return Err(info_err!(format!("No {cluster} entries found for target {}", &target.name))); } - let file_lock = config.file_locks.read_lock(&xtream_path).await; + let file_lock = app_config.file_locks.read_lock(&xtream_path).await; let reader = IndexedDocumentIterator::::new(&xtream_path, &idx_path) .map_err(|err| info_err!(format!("Could not deserialize file {xtream_path:?} - {err}")))?; - let options = XtreamMappingOptions::from_target_options(target, xtream_output, config); - let server_info = config.get_user_server_info(user); + let options = XtreamMappingOptions::from_target_options(target, xtream_output, app_config); + let server_info = app_config.get_user_server_info(user); - let filter = user_get_bouquet_filter(config, &user.username, category_id, TargetType::Xtream, cluster).await; + let filter = user_get_bouquet_filter(&config, &user.username, category_id, TargetType::Xtream, cluster).await; Ok(Self { reader, @@ -103,7 +104,7 @@ pub struct XtreamPlaylistJsonIterator { impl XtreamPlaylistJsonIterator { pub async fn new( cluster: XtreamCluster, - config: &Config, + config: &AppConfig, target: &ConfigTarget, category_id: Option, user: &ProxyUserCredentials, @@ -114,10 +115,15 @@ pub async fn new( } } +pub fn to_doc(pli: &XtreamPlaylistItem, url: &str, options: &XtreamMappingOptions, user: &ProxyUserCredentials) -> Value { + xtream_playlistitem_to_document(pli, url, options, user) +} + + impl Iterator for XtreamPlaylistJsonIterator { type Item = (String, bool); fn next(&mut self) -> Option { - self.inner.get_next().map(|(pli, has_next)| (pli.to_doc(&self.inner.base_url, &self.inner.options, &self.inner.user).to_string(), has_next)) + self.inner.get_next().map(|(pli, has_next)| (to_doc(&pli, &self.inner.base_url, &self.inner.options, &self.inner.user).to_string(), has_next)) } } diff --git a/backend/src/repository/xtream_repository.rs b/backend/src/repository/xtream_repository.rs index 4cb992da7..e9b68b527 100644 --- a/backend/src/repository/xtream_repository.rs +++ b/backend/src/repository/xtream_repository.rs @@ -1,13 +1,11 @@ use shared::error::{create_tuliprox_error, create_tuliprox_error_result, info_err, notify_err, str_to_io_error, to_io_error, TuliproxError, TuliproxErrorKind}; -use crate::model::{ProxyUserCredentials}; +use crate::model::{AppConfig, ProxyUserCredentials}; use crate::model::{Config, ConfigInput, ConfigTarget, XtreamTargetOutput}; -use crate::model::{PlaylistGroup, PlaylistItem, XtreamPlaylistItem}; use crate::model::{rewrite_doc_urls, PlaylistXtreamCategory, XtreamMappingOptions, XtreamSeriesEpisode}; use crate::model::normalize_release_date; use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery, BPlusTreeUpdate}; use crate::repository::indexed_document::{IndexedDocumentDirectAccess, IndexedDocumentGarbageCollector, IndexedDocumentIterator, IndexedDocumentWriter}; use crate::repository::playlist_repository::get_target_id_mapping; -use crate::utils::hex_encode; use crate::repository::storage::{get_input_storage_path, get_target_id_mapping_file, get_target_storage_path}; use crate::repository::storage_const; use crate::repository::target_id_mapping::VirtualIdRecord; @@ -16,8 +14,7 @@ use crate::utils::bincode_deserialize; use crate::utils::FileReadGuard; use crate::utils::file_reader; use crate::utils::open_readonly_file; -use crate::utils::generate_playlist_uuid; -use crate::utils::{get_u32_from_serde_value, json_iter_array, json_write_documents_to_file}; +use crate::utils::{json_write_documents_to_file}; use bytes::Bytes; use futures::{stream, Stream, StreamExt}; use log::error; @@ -28,8 +25,8 @@ use std::fs; use std::fs::File; use std::io::{BufReader, BufWriter, Error, ErrorKind, Read, Write}; use std::path::{Path, PathBuf}; -use std::sync::Arc; -use shared::model::{PlaylistEntry, PlaylistItemType, XtreamCluster}; +use shared::model::{PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}; +use shared::utils::{generate_playlist_uuid, get_u32_from_serde_value, hex_encode, json_iter_array}; macro_rules! cant_write_result { ($path:expr, $err:expr) => { @@ -104,7 +101,7 @@ pub fn xtream_get_record_file_path(storage_path: &Path, item_type: PlaylistItemT } } fn write_playlists_to_file( - cfg: &Config, + cfg: &AppConfig, storage_path: &Path, collections: Vec<(XtreamCluster, &[&mut PlaylistItem])>, ) -> Result<(), TuliproxError> { @@ -185,9 +182,12 @@ pub fn xtream_get_file_paths_for_series(storage_path: &Path) -> (PathBuf, PathBu xtream_get_file_paths_for_name(storage_path, storage_const::FILE_SERIES) } -fn xtream_garbage_collect(config: &Config, target_name: &str) -> std::io::Result<()> { +fn xtream_garbage_collect(config: &AppConfig, target_name: &str) -> std::io::Result<()> { // Garbage collect series - let storage_path = try_option_ok!(xtream_get_storage_path(config, target_name)); + let storage_path = { + let cfg = config.config.load(); + try_option_ok!(xtream_get_storage_path(&cfg, target_name)) + }; let (info_path, idx_path) = try_option_ok!(xtream_get_info_file_paths( &storage_path, XtreamCluster::Series @@ -200,11 +200,14 @@ fn xtream_garbage_collect(config: &Config, target_name: &str) -> std::io::Result } pub async fn xtream_write_playlist( + cfg: &AppConfig, target: &ConfigTarget, - cfg: &Config, playlist: &mut [PlaylistGroup], ) -> Result<(), TuliproxError> { - let path = ensure_xtream_storage_path(cfg, target.name.as_str())?; + let path = { + let config = cfg.config.load(); + ensure_xtream_storage_path(&config, target.name.as_str())? + }; let mut errors = Vec::new(); let mut cat_live_col = Vec::with_capacity(1_000); let mut cat_series_col = Vec::with_capacity(1_000); @@ -308,7 +311,7 @@ pub fn xtream_get_collection_path( } fn xtream_read_item_for_stream_id( - cfg: &Config, + cfg: &AppConfig, stream_id: u32, storage_path: &Path, cluster: XtreamCluster, @@ -321,7 +324,7 @@ fn xtream_read_item_for_stream_id( } fn xtream_read_series_item_for_stream_id( - cfg: &Config, + cfg: &AppConfig, stream_id: u32, storage_path: &Path, ) -> Result { @@ -342,39 +345,40 @@ macro_rules! try_cluster { pub fn xtream_get_item_for_stream_id( virtual_id: u32, - config: &Config, + app_config: &AppConfig, target: &ConfigTarget, xtream_cluster: Option, ) -> Result<(XtreamPlaylistItem, VirtualIdRecord), Error> { - let target_path = get_target_storage_path(config, target.name.as_str()).ok_or_else(|| str_to_io_error(&format!("Could not find path for target {}", &target.name)))?; - let storage_path = xtream_get_storage_path(config, target.name.as_str()).ok_or_else(|| str_to_io_error(&format!("Could not find path for target {} xtream output", &target.name)))?; + let config = app_config.config.load(); + let target_path = get_target_storage_path(&config, target.name.as_str()).ok_or_else(|| str_to_io_error(&format!("Could not find path for target {}", &target.name)))?; + let storage_path = xtream_get_storage_path(&config, target.name.as_str()).ok_or_else(|| str_to_io_error(&format!("Could not find path for target {} xtream output", &target.name)))?; { let target_id_mapping_file = get_target_id_mapping_file(&target_path); - let _file_lock = config.file_locks.read_lock(&target_id_mapping_file); + let _file_lock = app_config.file_locks.read_lock(&target_id_mapping_file); let mut target_id_mapping = BPlusTreeQuery::::try_new(&target_id_mapping_file).map_err(|err| str_to_io_error(&format!("Could not load id mapping for target {} err:{err}", target.name)))?; let mapping = target_id_mapping.query(&virtual_id).ok_or_else(|| str_to_io_error(&format!("Could not find mapping for target {} and id {}", target.name, virtual_id)))?; let result = match mapping.item_type { PlaylistItemType::SeriesInfo => { - xtream_read_series_item_for_stream_id(config, virtual_id, &storage_path) + xtream_read_series_item_for_stream_id(app_config, virtual_id, &storage_path) } PlaylistItemType::Series => { - if let Ok(mut item) = xtream_read_series_item_for_stream_id(config, mapping.parent_virtual_id, &storage_path) { + if let Ok(mut item) = xtream_read_series_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path) { item.provider_id = mapping.provider_id; Ok(item) } else { - xtream_read_item_for_stream_id(config, virtual_id, &storage_path, XtreamCluster::Series) + xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, XtreamCluster::Series) } } PlaylistItemType::Catchup => { let cluster = try_cluster!(xtream_cluster, mapping.item_type, virtual_id)?; - let mut item = xtream_read_item_for_stream_id(config, mapping.parent_virtual_id, &storage_path, cluster)?; + let mut item = xtream_read_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path, cluster)?; item.provider_id = mapping.provider_id; Ok(item) } _ => { let cluster = try_cluster!(xtream_cluster, mapping.item_type, virtual_id)?; - xtream_read_item_for_stream_id(config, virtual_id, &storage_path, cluster) + xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, cluster) } }; @@ -384,7 +388,7 @@ pub fn xtream_get_item_for_stream_id( pub async fn xtream_load_rewrite_playlist( cluster: XtreamCluster, - config: &Config, + config: &AppConfig, target: &ConfigTarget, category_id: Option, user: &ProxyUserCredentials, @@ -393,27 +397,28 @@ pub async fn xtream_load_rewrite_playlist( } pub fn xtream_write_series_info( - config: &Config, + app_config: &AppConfig, target_name: &str, series_info_id: u32, content: &str, ) -> Result<(), Error> { - let target_path = try_option_ok!(get_target_storage_path(config, target_name)); - let storage_path = try_option_ok!(xtream_get_storage_path(config, target_name)); + let config = app_config.config.load(); + let target_path = try_option_ok!(get_target_storage_path(&config, target_name)); + let storage_path = try_option_ok!(xtream_get_storage_path(&config, target_name)); let (info_path, idx_path) = try_option_ok!(xtream_get_info_file_paths( &storage_path, XtreamCluster::Series )); { - let _file_lock = config.file_locks.write_lock(&info_path); + let _file_lock = app_config.file_locks.write_lock(&info_path); let mut writer = IndexedDocumentWriter::new_append(info_path.clone(), idx_path)?; writer.write_doc(series_info_id, content).map_err(|_| str_to_io_error(&format!("failed to write xtream series info for target {target_name}")))?; writer.store()?; } { let target_id_mapping_file = get_target_id_mapping_file(&target_path); - let _file_lock = config.file_locks.write_lock(&target_id_mapping_file); + let _file_lock = app_config.file_locks.write_lock(&target_id_mapping_file); if let Ok(mut target_id_mapping) = BPlusTreeUpdate::::try_new(&target_id_mapping_file) { if let Some(record) = target_id_mapping.query(&series_info_id) { let new_record = record.copy_update_timestamp(); @@ -426,15 +431,16 @@ pub fn xtream_write_series_info( } pub async fn xtream_write_vod_info( - config: &Config, + app_config: &AppConfig, target_name: &str, virtual_id: u32, content: &str, ) -> Result<(), Error> { - let storage_path = try_option_ok!(xtream_get_storage_path(config, target_name)); + let config = app_config.config.load(); + let storage_path = try_option_ok!(xtream_get_storage_path(&config, target_name)); let (info_path, idx_path) = try_option_ok!(xtream_get_info_file_paths(&storage_path, XtreamCluster::Video)); { - let _file_lock = config.file_locks.write_lock(&info_path).await; + let _file_lock = app_config.file_locks.write_lock(&info_path).await; let mut writer = IndexedDocumentWriter::new_append(info_path.clone(), idx_path)?; writer.write_doc(virtual_id, content).map_err(|_| str_to_io_error(&format!("failed to write xtream vod info for target {target_name}")))?; writer.store()?; @@ -443,18 +449,19 @@ pub async fn xtream_write_vod_info( } fn xtream_get_series_info_mapping( - config: &Config, + config: &AppConfig, target_name: &str, series_id: u32, ) -> Option { xtream_get_info_mapping(config, target_name, series_id).filter(|id_record| !id_record.is_expired()) } -fn xtream_get_info_mapping(config: &Config, target_name: &str, info_id: u32) -> Option { - let target_path = get_target_storage_path(config, target_name)?; +fn xtream_get_info_mapping(app_config: &AppConfig, target_name: &str, info_id: u32) -> Option { + let config = app_config.config.load(); + let target_path = get_target_storage_path(&config, target_name)?; let target_id_mapping_file = get_target_id_mapping_file(&target_path); - let _file_lock = config.file_locks.read_lock(&target_id_mapping_file); + let _file_lock = app_config.file_locks.read_lock(&target_id_mapping_file); BPlusTreeQuery::::try_new(&target_id_mapping_file).map_err(|err| { error!("Could not load id mapping for target {target_name}: {err}"); str_to_io_error(&format!("ID mapping load error for target {target_name}")) @@ -463,19 +470,19 @@ fn xtream_get_info_mapping(config: &Config, target_name: &str, info_id: u32) -> // Reads the series info entry if exists pub fn xtream_load_series_info( - config: &Config, + app_config: &AppConfig, target_name: &str, series_id: u32, ) -> Option { - xtream_get_series_info_mapping(config, target_name, series_id)?; - - let storage_path = xtream_get_storage_path(config, target_name)?; + xtream_get_series_info_mapping(app_config, target_name, series_id)?; + let config = app_config.config.load(); + let storage_path = xtream_get_storage_path(&config, target_name)?; let (info_path, idx_path) = xtream_get_info_file_paths(&storage_path, XtreamCluster::Series)?; if info_path.exists() && idx_path.exists() { { - let _file_lock = config.file_locks.read_lock(&info_path); + let _file_lock = app_config.file_locks.read_lock(&info_path); return match IndexedDocumentDirectAccess::read_indexed_item::(&info_path, &idx_path, &series_id) { Ok(content) => Some(content), Err(err) => { @@ -488,7 +495,7 @@ pub fn xtream_load_series_info( None } fn xtream_get_vod_info_mapping( - config: &Config, + config: &AppConfig, target_name: &str, vod_id: u32, ) -> Option { @@ -498,7 +505,7 @@ fn xtream_get_vod_info_mapping( // Reads the vod info entry if exists pub fn xtream_load_vod_info( - config: &Config, + config: &AppConfig, target_name: &str, vod_id: u32, ) -> Option { @@ -506,7 +513,7 @@ pub fn xtream_load_vod_info( // Check if the entry exists; if not, we don't need to look further. xtream_get_vod_info_mapping(config, target_name, vod_id).as_ref()?; // Entry exists, read db entry - let target_storage_path = xtream_get_storage_path(config, target_name)?; + let target_storage_path = xtream_get_storage_path(&config.config.load(), target_name)?; let (info_path, idx_path) = xtream_get_info_file_paths(&target_storage_path, XtreamCluster::Video)?; @@ -520,7 +527,7 @@ pub fn xtream_load_vod_info( } fn rewrite_xtream_vod_info

( - config: &Config, + config: &AppConfig, target: &ConfigTarget, xtream_output: &XtreamTargetOutput, pli: &P, @@ -567,7 +574,7 @@ fn rewrite_xtream_vod_info

( } pub fn rewrite_xtream_vod_info_content

( - config: &Config, + config: &AppConfig, target: &ConfigTarget, xtream_output: &XtreamTargetOutput, pli: &P, @@ -581,7 +588,7 @@ pub fn rewrite_xtream_vod_info_content

( } pub async fn write_and_get_xtream_vod_info

( - config: &Config, + app_config: &AppConfig, target: &ConfigTarget, xtream_output: &XtreamTargetOutput, pli: &P, @@ -591,12 +598,12 @@ pub async fn write_and_get_xtream_vod_info

( P: PlaylistEntry, { let mut doc = serde_json::from_str::>(content).map_err(|_| str_to_io_error("Failed to parse JSON content"))?; - xtream_write_vod_info(config, target.name.as_str(), pli.get_virtual_id(), content).await.ok(); - rewrite_xtream_vod_info(config, target, xtream_output, pli, user, &mut doc) + xtream_write_vod_info(app_config, target.name.as_str(), pli.get_virtual_id(), content).await.ok(); + rewrite_xtream_vod_info(app_config, target, xtream_output, pli, user, &mut doc) } async fn rewrite_xtream_series_info

( - config: &Config, + app_config: &AppConfig, target: &ConfigTarget, xtream_output: &XtreamTargetOutput, pli: &P, @@ -605,12 +612,13 @@ async fn rewrite_xtream_series_info

( ) -> Result where P: PlaylistEntry, { - let target_path = get_target_storage_path(config, target.name.as_str()).ok_or_else(|| str_to_io_error(&format!("Could not find path for target {}", target.name)))?; + let config = app_config.config.load(); + let target_path = get_target_storage_path(&config, target.name.as_str()).ok_or_else(|| str_to_io_error(&format!("Could not find path for target {}", target.name)))?; - let resource_url = if config.is_reverse_proxy_resource_rewrite_enabled() { + let resource_url = if app_config.is_reverse_proxy_resource_rewrite_enabled() { let item_type = pli.get_item_type(); if user.proxy.is_reverse(item_type) && !target.is_force_redirect(item_type) { - let server_info = config.get_user_server_info(user); + let server_info = app_config.get_user_server_info(user); let url = server_info.get_base_url(); Some(format!("{url}/resource/series/{}/{}/{}", user.username, user.password, pli.get_virtual_id())) } else { @@ -640,8 +648,8 @@ async fn rewrite_xtream_series_info

( let virtual_id = pli.get_virtual_id(); { - let (mut target_id_mapping, file_lock) = get_target_id_mapping(config, &target_path).await; - let options = XtreamMappingOptions::from_target_options(target, xtream_output, config); + let (mut target_id_mapping, file_lock) = get_target_id_mapping(app_config, &target_path).await; + let options = XtreamMappingOptions::from_target_options(target, xtream_output, app_config); let provider_url = pli.get_provider_url(); for episode_list in episodes.values_mut().filter_map(Value::as_array_mut) { @@ -682,7 +690,7 @@ async fn rewrite_xtream_series_info

( } pub async fn rewrite_xtream_series_info_content

( - config: &Config, + config: &AppConfig, target: &ConfigTarget, xtream_output: &XtreamTargetOutput, pli_series_info: &P, @@ -696,7 +704,7 @@ pub async fn rewrite_xtream_series_info_content

( } pub async fn write_and_get_xtream_series_info

( - config: &Config, + config: &AppConfig, target: &ConfigTarget, xtream_output: &XtreamTargetOutput, pli_series_info: &P, @@ -712,12 +720,12 @@ pub async fn write_and_get_xtream_series_info

( } pub fn xtream_get_input_info( - cfg: &Config, + cfg: &AppConfig, input: &ConfigInput, provider_id: u32, cluster: XtreamCluster, ) -> Option { - if let Ok(Some((info_path, idx_path))) = get_input_storage_path(&input.name, &cfg.working_dir).map(|storage_path| xtream_get_info_file_paths(&storage_path, cluster)) + if let Ok(Some((info_path, idx_path))) = get_input_storage_path(&input.name, &cfg.config.load().working_dir).map(|storage_path| xtream_get_info_file_paths(&storage_path, cluster)) { let _file_lock = cfg.file_locks.read_lock(&info_path); if let Ok(content) = IndexedDocumentDirectAccess::read_indexed_item::(&info_path, &idx_path, &provider_id) { @@ -728,12 +736,13 @@ pub fn xtream_get_input_info( } pub async fn xtream_update_input_info_file( - cfg: &Config, + cfg: &AppConfig, input: &ConfigInput, wal_path: &Path, cluster: XtreamCluster, ) -> Result<(), TuliproxError> { - match get_input_storage_path(&input.name, &cfg.working_dir).map(|storage_path| xtream_get_info_file_paths(&storage_path, cluster)) { + let config = cfg.config.load(); + match get_input_storage_path(&input.name, &config.working_dir).map(|storage_path| xtream_get_info_file_paths(&storage_path, cluster)) { Ok(Some((info_path, idx_path))) => { { let _file_lock = cfg.file_locks.write_lock(&info_path); @@ -772,11 +781,12 @@ pub async fn xtream_update_input_info_file( } pub async fn xtream_update_input_vod_record_from_wal_file( - cfg: &Config, + cfg: &AppConfig, input: &ConfigInput, wal_path: &Path, ) -> Result<(), TuliproxError> { - let record_path = get_input_storage_path(&input.name, &cfg.working_dir).map(|storage_path| xtream_get_record_file_path(&storage_path, PlaylistItemType::Video)) + let config = cfg.config.load(); + let record_path = get_input_storage_path(&input.name, &config.working_dir).map(|storage_path| xtream_get_record_file_path(&storage_path, PlaylistItemType::Video)) .map_err(|err| notify_err!(format!("Error accessing storage path: {err}"))) .and_then(|opt| opt.ok_or_else(|| notify_err!(format!("Error accessing storage path for input: {}", &input.name))))?; @@ -840,11 +850,12 @@ pub async fn xtream_update_input_vod_record_from_wal_file( } pub async fn xtream_update_input_series_record_from_wal_file( - cfg: &Config, + cfg: &AppConfig, input: &ConfigInput, wal_path: &Path, ) -> Result<(), TuliproxError> { - let record_path = get_input_storage_path(&input.name, &cfg.working_dir).map(|storage_path| xtream_get_record_file_path(&storage_path, PlaylistItemType::SeriesInfo)) + let config = cfg.config.load(); + let record_path = get_input_storage_path(&input.name, &config.working_dir).map(|storage_path| xtream_get_record_file_path(&storage_path, PlaylistItemType::SeriesInfo)) .map_err(|err| notify_err!(format!("Error accessing storage path: {err}"))) .and_then(|opt| opt.ok_or_else(|| notify_err!(format!("Error accessing storage path for input: {}", &input.name))))?; { @@ -874,11 +885,12 @@ pub async fn xtream_update_input_series_record_from_wal_file( } pub async fn xtream_update_input_series_episodes_record_from_wal_file( - cfg: &Config, + cfg: &AppConfig, input: &ConfigInput, wal_path: &Path, ) -> Result<(), TuliproxError> { - let record_path = get_input_storage_path(&input.name, &cfg.working_dir).map(|storage_path| xtream_get_record_file_path(&storage_path, PlaylistItemType::Series)) + let config = cfg.config.load(); + let record_path = get_input_storage_path(&input.name, &config.working_dir).map(|storage_path| xtream_get_record_file_path(&storage_path, PlaylistItemType::Series)) .map_err(|err| notify_err!(format!("Error accessing storage path: {err}"))) .and_then(|opt| opt.ok_or_else(|| notify_err!(format!("Error accessing storage path for input: {}", &input.name))))?; { @@ -924,13 +936,14 @@ pub async fn xtream_update_input_series_episodes_record_from_wal_file( } } -pub async fn iter_raw_xtream_playlist(config: &Arc, target: &ConfigTarget, cluster: XtreamCluster) -> Option<(FileReadGuard, impl Iterator)> { - if let Some(storage_path) = xtream_get_storage_path(config, target.name.as_str()) { +pub async fn iter_raw_xtream_playlist(app_config: &AppConfig, target: &ConfigTarget, cluster: XtreamCluster) -> Option<(FileReadGuard, impl Iterator)> { + let config = app_config.config.load(); + if let Some(storage_path) = xtream_get_storage_path(&config, target.name.as_str()) { let (xtream_path, idx_path) = xtream_get_file_paths(&storage_path, cluster); if !xtream_path.exists() || !idx_path.exists() { return None; } - let file_lock = config.file_locks.read_lock(&xtream_path).await; + let file_lock = app_config.file_locks.read_lock(&xtream_path).await; match IndexedDocumentIterator::::new(&xtream_path, &idx_path) .map_err(|err| info_err!(format!("Could not deserialize file {xtream_path:?} - {err}"))) { Ok(reader) => Some((file_lock, reader)), diff --git a/backend/src/tools/lru_cache.rs b/backend/src/tools/lru_cache.rs index 449abece5..561dfe957 100644 --- a/backend/src/tools/lru_cache.rs +++ b/backend/src/tools/lru_cache.rs @@ -1,5 +1,5 @@ -use crate::utils::{hash_string_as_hex, traverse_dir}; -use shared::utils::human_readable_byte_size; +use crate::utils::{traverse_dir}; +use shared::utils::{hash_string_as_hex, human_readable_byte_size}; use log::{debug, error, info, trace}; use std::collections::{HashMap, VecDeque}; use std::fs; diff --git a/backend/src/tools/mod.rs b/backend/src/tools/mod.rs index 6e3dbf2f0..e7dcbf55d 100644 --- a/backend/src/tools/mod.rs +++ b/backend/src/tools/mod.rs @@ -1,3 +1,2 @@ -pub mod directed_graph; pub mod lru_cache; pub mod atomic_once_flag; \ No newline at end of file diff --git a/backend/src/utils/file/config_reader.rs b/backend/src/utils/file/config_reader.rs index 650a07a8c..24c1835be 100644 --- a/backend/src/utils/file/config_reader.rs +++ b/backend/src/utils/file/config_reader.rs @@ -1,7 +1,7 @@ -use crate::model::{ApiProxyConfig, SourcesConfig}; +use crate::model::{ApiProxyConfig, AppConfig, SourcesConfig}; use crate::model::{Config}; use shared::error::{create_tuliprox_error, info_err, to_io_error, TuliproxError, TuliproxErrorKind}; -use crate::utils::{open_file, EnvResolvingReader}; +use crate::utils::{open_file, EnvResolvingReader, FileLockManager}; use crate::utils::{file_reader}; use crate::utils::sys_utils::exit; use shared::utils::CONSTANTS; @@ -13,7 +13,8 @@ use std::fs::File; use std::io::{self, BufReader, Read}; use std::path::{PathBuf}; use std::sync::Arc; -use shared::model::ConfigDto; +use arc_swap::{ArcSwap, ArcSwapAny}; +use shared::model::{ApiProxyConfigDto, ConfigDto, SourcesConfigDto}; use crate::utils; enum EitherReader { @@ -40,7 +41,7 @@ pub fn config_file_reader(file: File, resolve_env: bool) -> impl Read } } -pub fn read_api_proxy_config(cfg: &Config) -> Result<(), TuliproxError> { +pub fn read_api_proxy_config(cfg: &AppConfig) -> Result<(), TuliproxError> { let api_proxy_config = read_api_proxy(cfg, true); match api_proxy_config { None => { @@ -54,11 +55,11 @@ pub fn read_api_proxy_config(cfg: &Config) -> Result<(), TuliproxError> { } } -pub fn read_sources(sources_file: &str, resolve_env: bool, include_computed: bool) -> Result { +pub fn read_sources(sources_file: &str, resolve_env: bool, include_computed: bool) -> Result { match open_file(&std::path::PathBuf::from(sources_file)) { Ok(file) => { - let maybe_sources: Result = serde_yaml::from_reader(config_file_reader(file, resolve_env)); + let maybe_sources: Result = serde_yaml::from_reader(config_file_reader(file, resolve_env)); match maybe_sources { Ok(mut sources) => { if let Err(err) = sources.prepare(include_computed) { @@ -74,53 +75,66 @@ pub fn read_sources(sources_file: &str, resolve_env: bool, include_computed: boo } } -pub fn read_config(config_path: &str, config_file: &str, sources_file: &str, api_proxy_file: &str, mappings_file: Option, include_computed: bool) -> Result { +pub fn read_config(config_path: &str, config_file: &str, sources_file: &str, api_proxy_file: &str, mappings_file: Option, include_computed: bool) -> Result { let resolve_env = true; - let sources = read_sources(sources_file, resolve_env, include_computed)?; + let sources_dto = read_sources(sources_file, resolve_env, include_computed)?; - match open_file(&std::path::PathBuf::from(config_file)) { + let config_dto = match open_file(&std::path::PathBuf::from(config_file)) { Ok(file) => { - let maybe_config: Result = serde_yaml::from_reader(config_file_reader(file, resolve_env)); + let maybe_config: Result = serde_yaml::from_reader(config_file_reader(file, resolve_env)); match maybe_config { - Ok(mut config) => { - config.sources = sources; - config.t_config_path = config_path.to_string(); - config.t_config_file_path = config_file.to_string(); - config.t_sources_file_path = sources_file.to_string(); - config.t_api_proxy_file_path = api_proxy_file.to_string(); - if let Err(err) = config.prepare(include_computed) { Err(err) } else { - if config.t_mapping_file_path.is_empty() { - config.t_mapping_file_path = resolve_env_var(&mappings_file.unwrap_or_else(|| utils::get_default_mappings_path(config_path))); - } - Ok(config) - } - } - Err(err) => Err(info_err!(format!("Can't read the config file: {config_file}: {err}"))) + Ok(mut config) => { + config.prepare()?; + config + }, + Err(err) => return Err(info_err!(format!("Can't read the config file: {config_file}: {err}"))) } - } - Err(err) => Err(info_err!(format!("Can't read the config file: {config_file}: {err}"))) - } + Err(err) => return Err(info_err!(format!("Can't read the config file: {config_file}: {err}"))) + }; + + let sources: SourcesConfig = sources_dto.into(); + let mut config: Config = Config::from(config_dto); + config.prepare(config_path)?; + + let mappings_file_path = config.mapping_path.as_ref().map_or_else(String::new, |mp| mp.trim().to_string()); + let mappings_file_path = if mappings_file_path.is_empty() { + resolve_env_var(&mappings_file.unwrap_or_else(|| utils::get_default_mappings_path(config_path))) + } else { + mappings_file_path + }; + + let mut app_config = AppConfig { + config: Arc::new(ArcSwap::from_pointee(config)), + sources: Arc::new(ArcSwap::from_pointee(sources)), + t_hdhomerun: Arc::new(ArcSwapAny::default()), + t_api_proxy: Arc::new(ArcSwapAny::default()), + t_config_path: config_path.to_string(), + t_config_file_path: config_file.to_string(), + t_sources_file_path: sources_file.to_string(), + t_mapping_file_path: mappings_file_path, + t_api_proxy_file_path: api_proxy_file.to_string(), + t_custom_stream_response_path: None, + file_locks: Arc::new(FileLockManager::default()), + t_custom_stream_response: None, + t_access_token_secret: Default::default(), + t_encrypt_secret: Default::default(), + }; + app_config.prepare(include_computed)?; + Ok(app_config) } -pub fn read_api_proxy(config: &Config, resolve_env: bool) -> Option { +pub fn read_api_proxy_dto(config: &AppConfig, resolve_env: bool) -> Option { let api_proxy_file = config.t_api_proxy_file_path.as_str(); open_file(&std::path::PathBuf::from(api_proxy_file)).map_or(None, |file| { - let maybe_api_proxy: Result = serde_yaml::from_reader(config_file_reader(file, resolve_env)); + let maybe_api_proxy: Result = serde_yaml::from_reader(config_file_reader(file, resolve_env)); match maybe_api_proxy { - Ok(mut api_proxy) => { - if let Err(err) = api_proxy.prepare() { + Ok(mut api_proxy_dto) => { + if let Err(err) = api_proxy_dto.prepare() { exit!("cant read api-proxy-config file: {err}"); } else { - let mut errors = vec![]; - api_proxy.migrate_api_user(config, &mut errors); - if !errors.is_empty() { - for error in errors { - error!("{error}"); - } - } - Some(api_proxy) + Some(api_proxy_dto) } } Err(err) => { @@ -131,6 +145,23 @@ pub fn read_api_proxy(config: &Config, resolve_env: bool) -> Option Option { + if let Some(api_proxy_dto) = read_api_proxy_dto(config, resolve_env) { + let mut errors = vec![]; + let mut api_proxy: ApiProxyConfig = api_proxy_dto.into(); + api_proxy.migrate_api_user(config, &mut errors); + if !errors.is_empty() { + for error in errors { + error!("{error}"); + } + } + Some(api_proxy) + } else { + None + } +} + fn write_config_file(file_path: &str, backup_dir: &str, config: &T, default_name: &str) -> Result<(), TuliproxError> where T: ?Sized + Serialize, @@ -151,7 +182,7 @@ where .map_err(|err| create_tuliprox_error!(TuliproxErrorKind::Info, "Could not write file {}: {}", &path.to_str().unwrap_or("?"), err)) } -pub fn save_api_proxy(file_path: &str, backup_dir: &str, config: &ApiProxyConfig) -> Result<(), TuliproxError> { +pub fn save_api_proxy(file_path: &str, backup_dir: &str, config: &ApiProxyConfigDto) -> Result<(), TuliproxError> { write_config_file(file_path, backup_dir, config, "api-proxy.yml") } diff --git a/backend/src/utils/file/csv_input_reader.rs b/backend/src/utils/file/csv_input_reader.rs index b93f6f1e6..477b785f9 100644 --- a/backend/src/utils/file/csv_input_reader.rs +++ b/backend/src/utils/file/csv_input_reader.rs @@ -1,6 +1,6 @@ -use crate::model::{ConfigInput, ConfigInputAlias, InputType}; +use crate::model::{ConfigInput, ConfigInputAlias}; use shared::error::{str_to_io_error, to_io_error}; -use crate::utils::request::{get_credentials_from_url, get_local_file_content}; +use crate::utils::request::{get_local_file_content}; use crate::utils::EnvResolvingReader; use crate::utils::{file_reader, resolve_relative_path}; use log::error; @@ -8,6 +8,8 @@ use std::io; use std::io::{BufRead, Cursor, Error}; use std::path::PathBuf; use url::Url; +use shared::model::InputType; +use shared::utils::get_credentials_from_url; const CSV_SEPARATOR: char = ';'; const HEADER_PREFIX: char = '#'; @@ -134,7 +136,6 @@ pub fn csv_read_inputs_from_reader(batch_input_type: InputType, reader: impl Buf password: None, priority: 0, max_connections: 1, - t_base_url: String::default(), }; let columns: Vec<&str> = line.split(CSV_SEPARATOR).collect(); @@ -180,10 +181,10 @@ fn get_csv_file_path(file_uri: &String) -> Result { #[cfg(test)] mod tests { - use crate::model::InputType; use crate::utils::file::csv_input_reader::csv_read_inputs_from_reader; use crate::utils::resolve_env_var; use std::io::{BufReader, Cursor}; + use shared::model::InputType; const M3U_BATCH: &str = r" #url;name;max_connections;priority diff --git a/backend/src/utils/file/mapping_reader.rs b/backend/src/utils/file/mapping_reader.rs index 52d2feac2..bfba68951 100644 --- a/backend/src/utils/file/mapping_reader.rs +++ b/backend/src/utils/file/mapping_reader.rs @@ -1,15 +1,15 @@ use std::collections::HashMap; -use crate::foundation::filter::PatternTemplate; -use crate::model::{Mapping, MappingDefinition, Mappings}; +use crate::model::{Mappings}; use shared::error::{create_tuliprox_error_result, info_err, TuliproxError, TuliproxErrorKind}; use crate::utils::traverse_dir; use crate::utils::{config_file_reader, open_file}; use log::{warn}; use std::path::{Path, PathBuf}; +use shared::model::{MappingDefinitionDto, MappingDto, MappingsDto, PatternTemplate}; -fn read_mapping(mapping_file: &Path, resolve_var: bool, prepare_mappings: bool) -> Result, TuliproxError> { +fn read_mapping(mapping_file: &Path, resolve_var: bool, prepare_mappings: bool) -> Result, TuliproxError> { if let Ok(file) = open_file(mapping_file) { - let maybe_mapping: Result = serde_yaml::from_reader(config_file_reader(file, resolve_var)); + let maybe_mapping: Result = serde_yaml::from_reader(config_file_reader(file, resolve_var)); return match maybe_mapping { Ok(mut mapping) => { if prepare_mappings { @@ -26,7 +26,7 @@ fn read_mapping(mapping_file: &Path, resolve_var: bool, prepare_mappings: bool) Ok(None) } -fn read_mappings_from_file(mappings_file: &Path, resolve_env: bool) -> Result, TuliproxError> { +fn read_mappings_from_file(mappings_file: &Path, resolve_env: bool) -> Result, TuliproxError> { match read_mapping(mappings_file, resolve_env, true) { Ok(mappings) => { match mappings { @@ -39,11 +39,11 @@ fn read_mappings_from_file(mappings_file: &Path, resolve_env: bool) -> Result) -> Vec { - let mut map: HashMap = HashMap::new(); +fn merge_mappings(mappings: Vec) -> Vec { + let mut map: HashMap = HashMap::new(); for mut m in mappings { - let entry = map.entry(m.id.clone()).or_insert_with(|| Mapping { + let entry = map.entry(m.id.clone()).or_insert_with(|| MappingDto { id: m.id.clone(), ..Default::default() }); @@ -62,9 +62,9 @@ fn merge_mappings(mappings: Vec) -> Vec { map.into_values().collect() } -fn merge_mapping_definitions(mappings: Vec) -> Result, TuliproxError> { +fn merge_mapping_definitions(mappings: Vec) -> Result, TuliproxError> { let mut merged_templates: Vec = Vec::new(); - let mut merged_mapping: Vec = Vec::new(); + let mut merged_mapping: Vec = Vec::new(); for mapping in mappings { if let Some(mut templates) = mapping.mappings.templates { @@ -74,8 +74,8 @@ fn merge_mapping_definitions(mappings: Vec) -> Result merged_mapping.extend(mapping.mappings.mapping); } - let mut result = Mappings { - mappings: MappingDefinition { + let mut result = MappingsDto { + mappings: MappingDefinitionDto { templates: if merged_templates.is_empty() { None } else { Some(merged_templates) }, mapping: merge_mappings(merged_mapping) } @@ -84,7 +84,7 @@ fn merge_mapping_definitions(mappings: Vec) -> Result Ok(Some(result)) } -fn read_mappings_from_directory(path: &Path, resolve_env: bool) -> Result, TuliproxError> { +fn read_mappings_from_directory(path: &Path, resolve_env: bool) -> Result, TuliproxError> { let mut files = vec![]; let mut visit = |entry: &std::fs::DirEntry, metadata: &std::fs::Metadata| { if metadata.is_file() { @@ -118,9 +118,9 @@ pub fn read_mappings(mappings_file: &str, resolve_env: bool) -> Result { if metadata.is_file() { - read_mappings_from_file(&path, resolve_env) + read_mappings_from_file(&path, resolve_env).map(|s| s.map(Into::into)) } else if metadata.is_dir() { - read_mappings_from_directory(&path, resolve_env) + read_mappings_from_directory(&path, resolve_env).map(|s| s.map(Into::into)) } else { Ok(None) } diff --git a/backend/src/utils/json_utils.rs b/backend/src/utils/json_utils.rs index 23df83108..2c57bf350 100644 --- a/backend/src/utils/json_utils.rs +++ b/backend/src/utils/json_utils.rs @@ -1,69 +1,12 @@ use std::collections::{HashMap, HashSet}; use std::fs::File; -use std::io::{self, BufReader, Error, Read, Write}; +use std::io::{BufReader, Error, Write}; use std::path::Path; - -use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; -use serde_json::{self, Deserializer, Value}; +use serde::Serialize; +use serde_json::Value; +use shared::utils::json_iter_array; use crate::utils::{file_reader, file_writer}; -fn read_skipping_ws(mut reader: impl Read) -> io::Result { - loop { - let mut byte = 0u8; - reader.read_exact(std::slice::from_mut(&mut byte))?; - if !byte.is_ascii_whitespace() { - return Ok(byte); - } - } -} - -fn invalid_data(msg: &str) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, msg) -} - -fn deserialize_single(reader: R) -> io::Result { - let next_obj = Deserializer::from_reader(reader).into_iter::().next(); - next_obj.map_or_else( - || Err(invalid_data("premature EOF")), - |result| result.map_err(Into::into), - ) -} - -fn yield_next_obj( - mut reader: R, - at_start: &mut bool, -) -> io::Result> { - if *at_start { - match read_skipping_ws(&mut reader)? { - b',' => deserialize_single(reader).map(Some), - b']' => Ok(None), - _ => Err(invalid_data("`,` or `]` not found")), - } - } else { - *at_start = true; - if read_skipping_ws(&mut reader)? == b'[' { - // read the next char to see if the array is empty - let peek = read_skipping_ws(&mut reader)?; - if peek == b']' { - Ok(None) - } else { - deserialize_single(io::Cursor::new([peek]).chain(reader)).map(Some) - } - } else { - Err(invalid_data("`[` not found")) - } - } -} - -// https://stackoverflow.com/questions/68641157/how-can-i-stream-elements-from-inside-a-json-array-using-serde-json -pub fn json_iter_array( - mut reader: R, -) -> impl Iterator> { - let mut at_start = false; - std::iter::from_fn(move || yield_next_obj(&mut reader, &mut at_start).transpose()) -} - pub fn json_filter_file(file_path: &Path, filter: &HashMap<&str, HashSet, S>) -> Vec { let mut filtered: Vec = Vec::with_capacity(1024); if !file_path.exists() { @@ -101,109 +44,3 @@ where serde_json::to_writer(&mut writer, value)?; writer.flush() } - -pub fn string_or_number_u32<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - let value: Value = serde::Deserialize::deserialize(deserializer)?; - - match value { - Value::Number(num) => { - if let Some(v) = num.as_u64() { - u32::try_from(v) - .map_err(|_| serde::de::Error::custom("Number out of range for u32")) - } else { - Err(serde::de::Error::custom("Invalid number")) - } - } - Value::String(s) => s - .parse::() - .map_err(|_| serde::de::Error::custom("Invalid string number")), - _ => Err(serde::de::Error::custom("Expected number or string")), - } -} - -pub fn opt_string_or_number_u32<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - let value: Value = serde::Deserialize::deserialize(deserializer)?; - - match value { - Value::Null => Ok(None), // Handle null explicitly - Value::Number(num) => { - if let Some(v) = num.as_u64() { - u32::try_from(v) - .map(Some) - .map_err(|_| serde::de::Error::custom("Number out of range for u32")) - } else { - Err(serde::de::Error::custom("Invalid number")) - } - } - Value::String(s) => s - .parse::() - .map(Some) - .map_err(|_| serde::de::Error::custom("Invalid string number")), - _ => Err(serde::de::Error::custom("Expected number, string, or null")), - } -} - -pub fn string_or_number_f64<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - let value: Value = serde::Deserialize::deserialize(deserializer)?; - - match value { - Value::Number(num) => num - .as_f64() - .ok_or_else(|| serde::de::Error::custom("Invalid number")), - Value::String(s) => s - .parse::() - .map_err(|_| serde::de::Error::custom("Invalid string number")), - _ => Err(serde::de::Error::custom("Expected number or string")), - } -} - -pub fn get_u64_from_serde_value(value: &Value) -> Option { - match value { - Value::Number(num_val) => num_val.as_u64(), - Value::String(str_val) => str_val.parse::().ok(), - _ => None, - } -} - -pub fn get_i64_from_serde_value(value: &Value) -> Option { - match value { - Value::Number(num_val) => num_val.as_i64(), - Value::String(str_val) => str_val.parse::().ok(), - _ => None, - } -} - -pub fn get_u32_from_serde_value(value: &Value) -> Option { - get_u64_from_serde_value(value).and_then(|val| u32::try_from(val).ok()) -} - -pub fn get_string_from_serde_value(value: &Value) -> Option { - match value { - Value::Number(num_val) => num_val.as_i64().map(|num| num.to_string()), - Value::String(str_val) => { - if str_val.is_empty() { - None - } else { - Some(str_val.clone()) - } - } - _ => None, - } -} - -pub fn string_default_on_null<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - let value: Option = Option::deserialize(deserializer)?; - Ok(value.unwrap_or_default()) -} \ No newline at end of file diff --git a/backend/src/utils/mod.rs b/backend/src/utils/mod.rs index 335227cdb..b235b5461 100644 --- a/backend/src/utils/mod.rs +++ b/backend/src/utils/mod.rs @@ -1,6 +1,4 @@ -mod json_utils; mod sys_utils; -mod hash_utils; mod compression; mod file; mod network; @@ -9,11 +7,10 @@ mod crypto_utils; mod step_measure; mod logging; mod trakt; -mod serde_utils; +mod json_utils; pub use self::logging::*; pub use self::trakt::*; -pub use self::serde_utils::*; #[macro_export] @@ -64,7 +61,6 @@ pub use with; pub use self::json_utils::*; pub use self::sys_utils::*; -pub use self::hash_utils::*; pub use self::compression::*; pub use self::file::*; pub use self::network::*; diff --git a/backend/src/utils/network/epg.rs b/backend/src/utils/network/epg.rs index 80f2705de..df7d8a2af 100644 --- a/backend/src/utils/network/epg.rs +++ b/backend/src/utils/network/epg.rs @@ -1,12 +1,12 @@ use shared::error::TuliproxError; -use crate::model::{Config, ConfigInput, PersistedEpgSource}; +use crate::model::{ConfigInput, PersistedEpgSource}; use crate::model::TVGuide; -use crate::utils::{add_prefix_to_filename, cleanup_unlisted_files_with_suffix, prepare_file_path, short_hash}; +use crate::utils::{add_prefix_to_filename, cleanup_unlisted_files_with_suffix, prepare_file_path}; use crate::utils::request; use log::debug; use std::path::PathBuf; use std::sync::Arc; -use crate::utils::request::sanitize_sensitive_info; +use shared::utils::{sanitize_sensitive_info, short_hash}; async fn download_epg_file(url: &str, client: &Arc, input: &ConfigInput, working_dir: &str) -> Result { debug!("Getting epg file path for url: {}", sanitize_sensitive_info(url)); @@ -17,7 +17,7 @@ async fn download_epg_file(url: &str, client: &Arc, input: &Con request::get_input_epg_content_as_file(Arc::clone(client), input, working_dir, url, persist_file_path).await } -pub async fn get_xmltv(client: Arc, _cfg: &Config, input: &ConfigInput, working_dir: &str) -> (Option, Vec) { +pub async fn get_xmltv(client: Arc, input: &ConfigInput, working_dir: &str) -> (Option, Vec) { match &input.epg { None => (None, vec![]), Some(epg_config) => { @@ -25,7 +25,7 @@ pub async fn get_xmltv(client: Arc, _cfg: &Config, input: &Conf let mut file_paths = vec![]; let mut stored_file_paths = vec![]; - for epg_source in &epg_config.t_sources { + for epg_source in &epg_config.sources { match download_epg_file(&epg_source.url, &client, input, working_dir).await { Ok(file_path) => { stored_file_paths.push(file_path.clone()); diff --git a/backend/src/utils/network/ip_checker.rs b/backend/src/utils/network/ip_checker.rs index f0300afef..230f5a235 100644 --- a/backend/src/utils/network/ip_checker.rs +++ b/backend/src/utils/network/ip_checker.rs @@ -3,7 +3,7 @@ use crate::model::IpCheckConfig; use regex::Regex; use reqwest::Client; use std::sync::Arc; -use crate::utils::request::sanitize_sensitive_info; +use shared::utils::sanitize_sensitive_info; async fn fetch_ip(client: &Arc, url: &str, regex: Option<&Regex>) -> Result { let response = client.get(url).send().await @@ -37,13 +37,13 @@ async fn fetch_combined_ips(client: &Arc, config: &IpCheckConfig, url: & if let Some(body) = text { let ipv4 = config - .t_pattern_ipv4 + .pattern_ipv4 .as_ref() .and_then(|re| re.captures(&body)) .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())); let ipv6 = config - .t_pattern_ipv6 + .pattern_ipv6 .as_ref() .and_then(|re| re.captures(&body)) .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())); @@ -60,8 +60,8 @@ pub async fn get_ips(client: &Arc, config: &IpCheckConfig) -> Result<(Op // Both dedicated URLs provided (Some(url_v4), Some(url_v6), _) => { let (ipv4, ipv6) = tokio::join!( - fetch_ip(client, url_v4, config.t_pattern_ipv4.as_ref()), - fetch_ip(client, url_v6, config.t_pattern_ipv6.as_ref()) + fetch_ip(client, url_v4, config.pattern_ipv4.as_ref()), + fetch_ip(client, url_v6, config.pattern_ipv6.as_ref()) ); Ok((ipv4.ok(), ipv6.ok())) } @@ -74,11 +74,11 @@ pub async fn get_ips(client: &Arc, config: &IpCheckConfig) -> Result<(Op // Only one dedicated URL (Some(url_v4), None, _) => { - let ipv4 = fetch_ip(client, url_v4, config.t_pattern_ipv4.as_ref()).await.ok(); + let ipv4 = fetch_ip(client, url_v4, config.pattern_ipv4.as_ref()).await.ok(); Ok((ipv4, None)) } (None, Some(url_v6), _) => { - let ipv6 = fetch_ip(client, url_v6, config.t_pattern_ipv6.as_ref()).await.ok(); + let ipv6 = fetch_ip(client, url_v6, config.pattern_ipv6.as_ref()).await.ok(); Ok((None, ipv6)) } diff --git a/backend/src/utils/network/m3u.rs b/backend/src/utils/network/m3u.rs index 3778226ad..922b7c369 100644 --- a/backend/src/utils/network/m3u.rs +++ b/backend/src/utils/network/m3u.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use shared::error::TuliproxError; +use shared::model::PlaylistGroup; use crate::model::{Config, ConfigInput}; -use crate::model::PlaylistGroup; use crate::processing::parser::m3u; use crate::utils::prepare_file_path; use crate::utils::request; diff --git a/backend/src/utils/network/request.rs b/backend/src/utils/network/request.rs index 4317d8786..b741fe64e 100644 --- a/backend/src/utils/network/request.rs +++ b/backend/src/utils/network/request.rs @@ -1,10 +1,8 @@ -use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::fs; use std::fs::File; use std::io::{BufWriter, Error, ErrorKind, Read, Write}; use std::path::{Path, PathBuf}; -use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -17,15 +15,43 @@ use url::Url; use shared::error::create_tuliprox_error_result; use shared::error::{str_to_io_error, TuliproxError, TuliproxErrorKind}; -use crate::model::{format_elapsed_time, Config}; -use crate::model::{ConfigInput, InputFetchMethod}; +use shared::model::InputFetchMethod; +use crate::model::{format_elapsed_time, AppConfig}; +use crate::model::{ConfigInput}; use crate::repository::storage::{get_input_storage_path}; use crate::repository::storage_const; use crate::utils::compression::compression_utils::{is_deflate, is_gzip}; -use crate::utils::{debug_if_enabled, short_hash}; -use shared::utils::{filter_request_header}; +use crate::utils::{debug_if_enabled}; +use shared::utils::{filter_request_header, sanitize_sensitive_info, short_hash, ENCODING_DEFLATE, ENCODING_GZIP}; use crate::utils::{get_file_path, persist_file}; -use shared::utils::{CONSTANTS, DASH_EXT, DASH_EXT_FRAGMENT, DASH_EXT_QUERY, ENCODING_DEFLATE, ENCODING_GZIP, HLS_EXT, HLS_EXT_FRAGMENT, HLS_EXT_QUERY}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MimeCategory { + Unknown, + Video, + M3U8, + Image, + Json, + Xml, + Text, + Unclassified, +} + +pub fn classify_content_type(headers: &[(String, String)]) -> MimeCategory { + headers.iter() + .find_map(|(k, v)| { + (k == axum::http::header::CONTENT_TYPE.as_str()).then_some(v) + }) + .map_or(MimeCategory::Unknown, |v| match v.to_lowercase().as_str() { + v if v.starts_with("video/") || v == "application/octet-stream" => MimeCategory::Video, + v if v.contains("mpegurl") => MimeCategory::M3U8, + v if v.starts_with("image/") => MimeCategory::Image, + v if v.starts_with("application/json") || v.ends_with("+json") => MimeCategory::Json, + v if v.starts_with("application/xml") || v.ends_with("+xml") || v == "text/xml" => MimeCategory::Xml, + v if v.starts_with("text/") => MimeCategory::Text, + _ => MimeCategory::Unclassified, + }) +} pub async fn get_input_epg_content_as_file(client: Arc, input: &ConfigInput, working_dir: &str, url_str: &str, persist_filepath: Option) -> Result { debug_if_enabled!("getting input epg content working_dir: {}, url: {}", working_dir, sanitize_sensitive_info(url_str)); @@ -365,157 +391,15 @@ pub async fn get_input_json_content(client: Arc, input: &Config } } -pub fn set_sanitize_sensitive_info(value: bool) { - CONSTANTS.sanitize.store(value, Ordering::SeqCst); -} -pub fn sanitize_sensitive_info(query: &str) -> Cow { - if !CONSTANTS.sanitize.load(Ordering::SeqCst) { - return Cow::Borrowed(query); - } - let mut result = query.to_owned(); - - for (re, replacement) in &[ - (&CONSTANTS.re_credentials, "$1***"), - (&CONSTANTS.re_ipv4, "$1***"), - (&CONSTANTS.re_ipv6, "$1***"), - (&CONSTANTS.re_stream_url, "$1***/$2/***"), - (&CONSTANTS.re_url, "$1***/$2"), - ] { - result = re.replace_all(&result, *replacement).into_owned(); - } - Cow::Owned(result) -} - -#[inline] -fn ensure_extension(ext: &str) -> Option<&str> { - if ext.len() > 4 { - return None; - } - Some(ext) -} - -pub fn extract_extension_from_url(url: &str) -> Option<&str> { - if let Some(protocol_pos) = url.find("://") { - if let Some(last_slash_pos) = url[protocol_pos + 3..].rfind('/') { - let path = &url[protocol_pos + 3 + last_slash_pos + 1..]; - if let Some(last_dot_pos) = path.rfind('.') { - return ensure_extension(&path[last_dot_pos..]); - } - } - } else if let Some(last_dot_pos) = url.rfind('.') { - if last_dot_pos > url.rfind('/').unwrap_or(0) { - return ensure_extension(&url[last_dot_pos..]); - } - } - None -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum MimeCategory { - Unknown, - Video, - M3U8, - Image, - Json, - Xml, - Text, - Unclassified, -} - -pub fn classify_content_type(headers: &[(String, String)]) -> MimeCategory { - headers.iter() - .find_map(|(k, v)| { - (k == axum::http::header::CONTENT_TYPE.as_str()).then_some(v) - }) - .map_or(MimeCategory::Unknown, |v| match v.to_lowercase().as_str() { - v if v.starts_with("video/") || v == "application/octet-stream" => MimeCategory::Video, - v if v.contains("mpegurl") => MimeCategory::M3U8, - v if v.starts_with("image/") => MimeCategory::Image, - v if v.starts_with("application/json") || v.ends_with("+json") => MimeCategory::Json, - v if v.starts_with("application/xml") || v.ends_with("+xml") || v == "text/xml" => MimeCategory::Xml, - v if v.starts_with("text/") => MimeCategory::Text, - _ => MimeCategory::Unclassified, - }) -} - -pub fn is_hls_url(url: &str) -> bool { - let lc_url = url.to_lowercase(); - lc_url.ends_with(HLS_EXT) || lc_url.contains(HLS_EXT_QUERY) || lc_url.contains(HLS_EXT_FRAGMENT) -} - -pub fn is_dash_url(url: &str) -> bool { - let lc_url = url.to_lowercase(); - lc_url.ends_with(DASH_EXT) || lc_url.contains(DASH_EXT_QUERY) || lc_url.contains(DASH_EXT_FRAGMENT) -} - -pub fn replace_url_extension(url: &str, new_ext: &str) -> String { - let ext = new_ext.strip_prefix('.').unwrap_or(new_ext); // Remove leading dot if exists - - // Split URL into the base part (domain and path) and the suffix (query/fragment) - let (base_url, suffix) = match url.find(['?', '#'].as_ref()) { - Some(pos) => (&url[..pos], &url[pos..]), // Base URL and suffix - None => (url, ""), // No query or fragment - }; - - // Find the last '/' in the base URL, which marks the end of the domain and the beginning of the file path - if let Some(last_slash_pos) = base_url.rfind('/') { - if last_slash_pos < 9 { // protocol slash, return url as is - return url.to_string(); - } - let (path_part, file_name_with_extension) = base_url.split_at(last_slash_pos + 1); - // Find the last dot in the file name to replace the extension - if let Some(dot_pos) = file_name_with_extension.rfind('.') { - return format!( - "{}{}.{}{}", - path_part, - &file_name_with_extension[..dot_pos], // Keep the name part before the dot - ext, // Add the new extension - suffix // Add the query or fragment if any - ); - } - } - - // If no extension is found, add the new extension to the base URL - format!("{}{}.{}{}", base_url, "", ext, suffix) -} - -pub fn get_credentials_from_url(url: &Url) -> (Option, Option) { - let mut username = None; - let mut password = None; - for (key, value) in url.query_pairs() { - if key.eq("username") { - username = Some(value.to_string()); - } else if key.eq("password") { - password = Some(value.to_string()); - } - } - (username, password) -} - -pub fn get_credentials_from_url_str(url_with_credentials: &str) -> (Option, Option) { - if let Ok(url) = Url::parse(url_with_credentials) { - get_credentials_from_url(&url) - } else { - (None, None) - } -} - -pub fn get_base_url_from_str(url: &str) -> Option { - if let Ok(url) = Url::parse(url) { - Some(url.origin().ascii_serialization()) - } else { - None - } -} - -pub fn create_client(cfg: &Config) -> reqwest::ClientBuilder { +pub fn create_client(cfg: &AppConfig) -> reqwest::ClientBuilder { let mut client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::limited(10)) .pool_idle_timeout(Duration::from_secs(30)) .pool_max_idle_per_host(10); - if let Some(proxy_cfg) = cfg.proxy.as_ref() { + let config = cfg.config.load(); + if let Some(proxy_cfg) = config.proxy.as_ref() { let proxy = match reqwest::Proxy::all(&proxy_cfg.url) { Ok(proxy) => { if let (Some(username), Some(password)) = (&proxy_cfg.username, &proxy_cfg.password) { @@ -536,7 +420,7 @@ pub fn create_client(cfg: &Config) -> reqwest::ClientBuilder { }; } - if let Some(rp_config) = cfg.reverse_proxy.as_ref() { + if let Some(rp_config) = config.reverse_proxy.as_ref() { if rp_config.disable_referer_header { client = client.referer(false); } @@ -547,7 +431,7 @@ pub fn create_client(cfg: &Config) -> reqwest::ClientBuilder { #[cfg(test)] mod tests { - use crate::utils::request::{get_base_url_from_str, replace_url_extension, sanitize_sensitive_info}; + use shared::utils::{get_base_url_from_str, replace_url_extension, sanitize_sensitive_info}; #[test] fn test_url_mask() { diff --git a/backend/src/utils/network/xtream.rs b/backend/src/utils/network/xtream.rs index c07c623d0..ff8545ed4 100644 --- a/backend/src/utils/network/xtream.rs +++ b/backend/src/utils/network/xtream.rs @@ -1,13 +1,10 @@ -use crate::model::ProxyUserCredentials; +use crate::model::{AppConfig, ProxyUserCredentials}; use crate::model::{Config, ConfigInput, ConfigTarget}; -use crate::model::{PlaylistGroup, XtreamPlaylistItem}; use crate::processing::parser::xtream; use crate::repository::xtream_repository; use crate::repository::xtream_repository::{rewrite_xtream_series_info_content, rewrite_xtream_vod_info_content, xtream_get_input_info}; use shared::error::{str_to_io_error, TuliproxError}; -use crate::utils; -use crate::utils::{get_string_from_serde_value, request}; -use crate::utils::request::extract_extension_from_url; +use crate::utils::{request}; use chrono::{DateTime}; use log::{info, warn}; use std::cmp::Ordering; @@ -15,7 +12,8 @@ use std::io::Error; use std::str::FromStr; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; -use shared::model::{MsgKind, PlaylistEntry, ProxyUserStatus, XtreamCluster}; +use shared::model::{MsgKind, PlaylistEntry, PlaylistGroup, ProxyUserStatus, XtreamCluster, XtreamPlaylistItem}; +use shared::utils::{extract_extension_from_url, get_i64_from_serde_value, get_string_from_serde_value}; use crate::messaging::{send_message}; #[inline] @@ -56,7 +54,7 @@ pub async fn get_xtream_stream_info_content(client: Arc, info_u #[allow(clippy::too_many_arguments)] pub async fn get_xtream_stream_info

(client: Arc, - config: &Config, + app_config: &AppConfig, user: &ProxyUserCredentials, input: &ConfigInput, target: &ConfigTarget, @@ -69,29 +67,29 @@ where let xtream_output = target.get_xtream_output().ok_or_else(|| Error::other("Unexpected error, missing xtream output"))?; if cluster == XtreamCluster::Series { - if let Some(content) = xtream_repository::xtream_load_series_info(config, target.name.as_str(), pli.get_virtual_id()) { + if let Some(content) = xtream_repository::xtream_load_series_info(app_config, target.name.as_str(), pli.get_virtual_id()) { // Deliver existing target content - return rewrite_xtream_series_info_content(config, target, xtream_output, pli, user, &content).await; + return rewrite_xtream_series_info_content(app_config, target, xtream_output, pli, user, &content).await; } // Check if the content has been resolved if xtream_output.resolve_series { if let Some(provider_id) = pli.get_provider_id() { - if let Some(content) = xtream_get_input_info(config, input, provider_id, XtreamCluster::Series) { - return xtream_repository::write_and_get_xtream_series_info(config, target, xtream_output, pli, user, &content).await; + if let Some(content) = xtream_get_input_info(app_config, input, provider_id, XtreamCluster::Series) { + return xtream_repository::write_and_get_xtream_series_info(app_config, target, xtream_output, pli, user, &content).await; } } } } else if cluster == XtreamCluster::Video { - if let Some(content) = xtream_repository::xtream_load_vod_info(config, target.name.as_str(), pli.get_virtual_id()) { + if let Some(content) = xtream_repository::xtream_load_vod_info(app_config, target.name.as_str(), pli.get_virtual_id()) { // Deliver existing target content - return rewrite_xtream_vod_info_content(config, target, xtream_output, pli, user, &content); + return rewrite_xtream_vod_info_content(app_config, target, xtream_output, pli, user, &content); } // Check if the content has been resolved if xtream_output.resolve_vod { if let Some(provider_id) = pli.get_provider_id() { - if let Some(content) = xtream_get_input_info(config, input, provider_id, XtreamCluster::Video) { - return xtream_repository::write_and_get_xtream_vod_info(config, target, xtream_output, pli, user, &content).await; + if let Some(content) = xtream_get_input_info(app_config, input, provider_id, XtreamCluster::Video) { + return xtream_repository::write_and_get_xtream_vod_info(app_config, target, xtream_output, pli, user, &content).await; } } } @@ -100,8 +98,8 @@ where if let Ok(content) = get_xtream_stream_info_content(client, info_url, input).await { return match cluster { XtreamCluster::Live => Ok(content), - XtreamCluster::Video => xtream_repository::write_and_get_xtream_vod_info(config, target, xtream_output, pli, user, &content).await, - XtreamCluster::Series => xtream_repository::write_and_get_xtream_series_info(config, target, xtream_output, pli, user, &content).await, + XtreamCluster::Video => xtream_repository::write_and_get_xtream_vod_info(app_config, target, xtream_output, pli, user, &content).await, + XtreamCluster::Series => xtream_repository::write_and_get_xtream_series_info(app_config, target, xtream_output, pli, user, &content).await, }; } @@ -150,7 +148,7 @@ async fn xtream_login(cfg: &Config, client: &Arc, input: &Confi None => {} Some(value) => { if let Some(status_value) = value.get("status") { - if let Some(status) = utils::get_string_from_serde_value(status_value) { + if let Some(status) = get_string_from_serde_value(status_value) { if let Ok(cur_status) = ProxyUserStatus::from_str(&status) { if !matches!(cur_status, ProxyUserStatus::Active | ProxyUserStatus::Trial) { warn!("User status for user {username} is {cur_status:?}"); @@ -160,7 +158,7 @@ async fn xtream_login(cfg: &Config, client: &Arc, input: &Confi } } if let Some(status_value) = value.get("exp_date") { - if let Some(expiration_timestamp) = utils::get_i64_from_serde_value(status_value) { + if let Some(expiration_timestamp) = get_i64_from_serde_value(status_value) { if expiration_timestamp > 0 { #[allow(clippy::cast_sign_loss)] let expiration_ts = expiration_timestamp as u64; diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 82a0d2b0c..55319a021 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -4,8 +4,18 @@ version = "0.1.0" edition = "2021" [dependencies] -bitflags = "2.9.1" -serde = { version = "1.0.219", features = ["derive"] } -rand = "0.9.1" -regex = "1.11.1" +bitflags = "2" +serde = { version = "1", features = ["derive"] } +rand = "0" +regex = "1" enum-iterator = "2" +log = "0" +path-clean = "1" +url = "2" +indexmap = "2" +pest = "2" +pest_derive = "2.8" +serde_json = "1" +base64 = "0" +blake3 = "1" +clap = { version = "4.5.40", features = ["derive"] } \ No newline at end of file diff --git a/backend/src/foundation/filter.pest b/shared/src/foundation/filter.pest similarity index 100% rename from backend/src/foundation/filter.pest rename to shared/src/foundation/filter.pest diff --git a/backend/src/foundation/filter.rs b/shared/src/foundation/filter.rs similarity index 97% rename from backend/src/foundation/filter.rs rename to shared/src/foundation/filter.rs index e809faace..52b8ba166 100644 --- a/backend/src/foundation/filter.rs +++ b/shared/src/foundation/filter.rs @@ -1,5 +1,6 @@ #![allow(clippy::empty_docs)] +use pest_derive::Parser; use std::borrow::Cow; use enum_iterator::all; use indexmap::IndexSet; @@ -8,12 +9,10 @@ use pest::iterators::Pair; use pest::Parser; use std::cmp::Ordering; use std::collections::HashMap; -use shared::model::{FieldGetAccessor, FieldSetAccessor, ItemField, PlaylistItemType}; -use crate::model::{PlaylistItem}; -use crate::tools::directed_graph::DirectedGraph; -use shared::error::{create_tuliprox_error_result, info_err}; -use shared::error::{TuliproxError, TuliproxErrorKind}; -use shared::utils::CONSTANTS; +use crate::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind}; +use crate::info_err; +pub use crate::model::{ItemField, PatternTemplate, PlaylistItem, PlaylistItemType, FieldGetAccessor, FieldSetAccessor, TemplateValue}; +use crate::utils::{DirectedGraph, CONSTANTS}; pub fn get_field_value(pli: &PlaylistItem, field: ItemField) -> String { let header = &pli.header; @@ -74,32 +73,6 @@ impl ValueAccessor<'_> { } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(untagged)] -pub enum TemplateValue { - Single(String), - Multi(Vec), -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct PatternTemplate { - pub name: String, - pub value: TemplateValue, - #[serde(skip)] - pub placeholder: String, -} - -impl PatternTemplate { - pub fn prepare(&mut self) { - let mut placeholder = String::with_capacity(self.name.len() + 2); - placeholder.push('!'); - placeholder.push_str(&self.name); - placeholder.push('!'); - - self.placeholder = placeholder; - } -} - #[derive(Debug, Clone)] pub struct CompiledRegex { pub restr: String, diff --git a/backend/src/foundation/mapper.pest b/shared/src/foundation/mapper.pest similarity index 100% rename from backend/src/foundation/mapper.pest rename to shared/src/foundation/mapper.pest diff --git a/backend/src/foundation/mapper.rs b/shared/src/foundation/mapper.rs similarity index 99% rename from backend/src/foundation/mapper.rs rename to shared/src/foundation/mapper.rs index 2e23478ed..c45ca6782 100644 --- a/backend/src/foundation/mapper.rs +++ b/shared/src/foundation/mapper.rs @@ -1,17 +1,19 @@ #![allow(clippy::empty_docs)] -use crate::foundation::filter::{PatternTemplate, TemplateValue, ValueAccessor}; -use crate::foundation::mapper::EvalResult::{AnyValue, Failure, Named, Number, Undefined, Value}; -use shared::error::{create_tuliprox_error_result, info_err, TuliproxError, TuliproxErrorKind}; -use shared::utils::Capitalize; +use crate::foundation::filter::{ValueAccessor}; use log::{debug, trace}; use pest::iterators::{Pair, Pairs}; -use pest::Parser; +use pest_derive::Parser; use regex::Regex; use std::borrow::Cow; use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::str::FromStr; +use pest::Parser; +use crate::error::{TuliproxError, TuliproxErrorKind, create_tuliprox_error_result, info_err}; +use crate::foundation::mapper::EvalResult::{AnyValue, Failure, Named, Number, Undefined, Value}; +use crate::model::{PatternTemplate, TemplateValue}; +use crate::utils::Capitalize; #[derive(Parser)] #[grammar_inline = r##" diff --git a/shared/src/foundation/mod.rs b/shared/src/foundation/mod.rs new file mode 100644 index 000000000..1dc915362 --- /dev/null +++ b/shared/src/foundation/mod.rs @@ -0,0 +1,8 @@ +pub mod filter; +pub mod mapper; +// +// pub use filter::{Filter, +// get_filter, prepare_templates, ValueProvider, ValueAccessor, +// get_field_value, set_field_value, +// apply_templates_to_pattern, apply_templates_to_pattern_single}; +// pub use mapper::*; diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 71087e205..8a9db0a85 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -1,3 +1,4 @@ pub mod error; pub mod model; -pub mod utils; \ No newline at end of file +pub mod utils; +pub mod foundation; diff --git a/shared/src/model/cluster_flags.rs b/shared/src/model/cluster_flags.rs index 2e8089342..d2fa8f863 100644 --- a/shared/src/model/cluster_flags.rs +++ b/shared/src/model/cluster_flags.rs @@ -5,7 +5,7 @@ use serde::de::{Error, SeqAccess, Visitor}; use crate::model::{PlaylistItemType, XtreamCluster}; bitflags! { - #[derive(Debug, Clone, PartialEq, Eq)] + #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub struct ClusterFlags: u16 { const Live = 1; // 0b0000_0001 const Vod = 1 << 1; // 0b0000_0010 diff --git a/shared/src/model/config/api.rs b/shared/src/model/config/api.rs new file mode 100644 index 000000000..1fe5dbc1b --- /dev/null +++ b/shared/src/model/config/api.rs @@ -0,0 +1,16 @@ +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct ConfigApiDto { + pub host: String, + pub port: u16, + #[serde(default)] + pub web_root: String, +} + +impl ConfigApiDto { + pub fn prepare(&mut self) { + if self.web_root.is_empty() { + self.web_root = String::from("./web"); + } + } +} \ No newline at end of file diff --git a/shared/src/model/config/api_proxy.rs b/shared/src/model/config/api_proxy.rs index ac69e8244..f6618974c 100644 --- a/shared/src/model/config/api_proxy.rs +++ b/shared/src/model/config/api_proxy.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; +use crate::error::{info_err, TuliproxError, TuliproxErrorKind}; use crate::model::{ProxyUserCredentialsDto}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -28,3 +30,124 @@ pub struct ApiProxyConfigDto { #[serde(default)] pub use_user_db: bool, } + +impl ApiProxyServerInfoDto { + pub fn prepare(&mut self) -> Result<(), TuliproxError> { + self.name = self.name.trim().to_string(); + if self.name.is_empty() { + return Err(info_err!("Server info name is empty ".to_string())); + } + self.protocol = self.protocol.trim().to_string(); + if self.protocol.is_empty() { + return Err(info_err!("protocol cant be empty for api server config".to_string())); + } + self.host = self.host.trim().to_string(); + if self.host.is_empty() { + return Err(info_err!("host cant be empty for api server config".to_string())); + } + if let Some(port) = self.port.as_ref() { + let port = port.trim().to_string(); + if port.is_empty() { + self.port = None; + } else if port.parse::().is_err() { + return Err(info_err!("invalid port for api server config".to_string())); + } else { + self.port = Some(port); + } + } + + self.timezone = self.timezone.trim().to_string(); + if self.timezone.is_empty() { + self.timezone = "UTC".to_string(); + } + if self.message.is_empty() { + self.message = "Welcome to tuliprox".to_string(); + } + if let Some(path) = &self.path { + if path.trim().is_empty() { + self.path = None; + } + } + + if let Some(path) = &self.path { + let trimmed_path = path.trim(); + if trimmed_path.is_empty() { + self.path = None; + } else { + self.path = Some(trimmed_path.to_string()); + } + } + + Ok(()) + } + pub fn validate(&mut self) -> bool { + self.prepare().is_ok() + } +} + +impl ApiProxyConfigDto { + + fn prepare_server_config(&mut self, errors: &mut Vec) { + let mut name_set = HashSet::new(); + for server in &mut self.server { + if let Err(err) = server.prepare() { + errors.push(err.to_string()); + } + if name_set.contains(server.name.as_str()) { + errors.push(format!("Non-unique server info name found {}", &server.name)); + } else { + name_set.insert(server.name.clone()); + } + } + } + + fn prepare_target_user(&mut self, errors: &mut Vec) { + let mut usernames = HashSet::new(); + let mut tokens = HashSet::new(); + for target_user in &mut self.user { + for user in &mut target_user.credentials { + user.prepare(); + if usernames.contains(&user.username) { + errors.push(format!("Non unique username found {}", &user.username)); + } else { + usernames.insert(user.username.to_string()); + } + if let Some(token) = &user.token { + if token.is_empty() { + user.token = None; + } else if tokens.contains(token) { + errors.push(format!("Non unique token found {}", &user.username)); + } else { + tokens.insert(token.to_string()); + } + } + + if let Some(server_info_name) = &user.server { + if !&self.server.iter() + .any(|server_info| server_info.name.eq(server_info_name)) + { + errors.push(format!( + "No server info with name {} found for user {}", + server_info_name, &user.username + )); + } + } + } + } + } + + pub fn prepare(&mut self) -> Result<(), TuliproxError> { + let mut errors = Vec::new(); + if self.server.is_empty() { + errors.push("No server info defined".to_string()); + } else { + self.prepare_server_config(&mut errors); + } + self.prepare_target_user(&mut errors); + if errors.is_empty() { + Ok(()) + } else { + Err(info_err!(errors.join("\n"))) + } + } +} \ No newline at end of file diff --git a/shared/src/model/config/api_user.rs b/shared/src/model/config/api_user.rs index ae9375a7a..66cccd411 100644 --- a/shared/src/model/config/api_user.rs +++ b/shared/src/model/config/api_user.rs @@ -14,7 +14,7 @@ pub enum UserConnectionPermission { GracePeriod, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum ProxyType { Reverse(Option), Redirect, @@ -202,3 +202,32 @@ pub struct ProxyUserCredentialsDto { #[serde(skip_serializing_if = "Option::is_none")] pub comment: Option, } + + +impl ProxyUserCredentialsDto { + pub fn prepare(&mut self) { + self.trim(); + } + + fn trim(&mut self) { + self.username = self.username.trim().to_string(); + self.password = self.password.trim().to_string(); + match &self.token { + None => {} + Some(tkn) => { + self.token = Some(tkn.trim().to_string()); + } + } + } + + pub fn validate(&self) -> Result<(), TuliproxError> { + if self.username.is_empty() { + return Err(TuliproxError::new(TuliproxErrorKind::Info, "Username required".to_string())); + } + if self.password.is_empty() { + return Err(TuliproxError::new(TuliproxErrorKind::Info, "Password required".to_string())); + } + Ok(()) + } + +} \ No newline at end of file diff --git a/shared/src/model/config/base.rs b/shared/src/model/config/base.rs index b5d481b99..f80947a6b 100644 --- a/shared/src/model/config/base.rs +++ b/shared/src/model/config/base.rs @@ -1,14 +1,7 @@ -use crate::model::{WebUiConfigDto, MessagingConfigDto, IpCheckConfigDto, HdHomeRunConfigDto, VideoConfigDto, ScheduleConfigDto, LogConfigDto, ReverseProxyConfigDto, ProxyConfigDto}; +use crate::error::{TuliproxError, TuliproxErrorKind}; +use crate::model::{WebUiConfigDto, MessagingConfigDto, IpCheckConfigDto, HdHomeRunConfigDto, VideoConfigDto, ScheduleConfigDto, LogConfigDto, ReverseProxyConfigDto, ProxyConfigDto, ConfigApiDto}; use crate::utils::{default_connect_timeout_secs}; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] -pub struct ConfigApiDto { - pub host: String, - pub port: u16, - #[serde(default)] - pub web_root: String, -} #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] @@ -57,6 +50,68 @@ pub struct ConfigDto { } impl ConfigDto { + + pub fn prepare(&mut self) -> Result<(), TuliproxError> { + if let Some(mins) = self.sleep_timer_mins { + if mins == 0 { + return Err(TuliproxError::new(TuliproxErrorKind::Info, "`sleep_timer_mins` must be > 0 when specified".to_string())); + } + } + + self.api.prepare(); + self.prepare_web()?; + self.prepare_hdhomerun()?; + self.prepare_video_config()?; + + if let Some(reverse_proxy) = self.reverse_proxy.as_mut() { + reverse_proxy.prepare(&self.working_dir)?; + } + if let Some(proxy) = &mut self.proxy { + proxy.prepare()?; + } + if let Some(ipcheck) = self.ipcheck.as_mut() { + ipcheck.prepare()?; + } + + Ok(()) + } + + fn prepare_web(&mut self) -> Result<(), TuliproxError> { + if let Some(web_ui_config) = self.web_ui.as_mut() { + web_ui_config.prepare()?; + } + Ok(()) + } + + fn prepare_hdhomerun(&mut self) -> Result<(), TuliproxError> { + if let Some(old_hdhomerun) = &self.hdhomerun { + let mut hdhomerun = (*old_hdhomerun).clone(); + if hdhomerun.enabled { + hdhomerun.prepare(self.api.port)?; + } + } + Ok(()) + } + + fn prepare_video_config(&mut self) -> Result<(), TuliproxError> { + match &mut self.video { + None => { + self.video = Some(VideoConfigDto { + extensions: vec!["mkv".to_string(), "avi".to_string(), "mp4".to_string()], + download: None, + web_search: None, + }); + } + Some(video) => { + match video.prepare() { + Ok(()) => {} + Err(err) => return Err(err) + } + } + } + Ok(()) + } + pub fn is_valid(&self) -> bool { if self.api.host.is_empty() { return false; diff --git a/shared/src/model/config/cache.rs b/shared/src/model/config/cache.rs index 29edb8aa5..f4cfe3b7d 100644 --- a/shared/src/model/config/cache.rs +++ b/shared/src/model/config/cache.rs @@ -1,3 +1,10 @@ +use std::path::PathBuf; +use crate::error::{TuliproxError, TuliproxErrorKind}; +use crate::info_err; +use crate::utils::parse_size_base_2; +use path_clean::PathClean; + + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct CacheConfigDto { @@ -7,4 +14,35 @@ pub struct CacheConfigDto { pub size: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub dir: Option, -} \ No newline at end of file +} + +impl CacheConfigDto { + pub(crate) fn prepare(&mut self, working_dir: &str) -> Result<(), TuliproxError> { + if self.enabled { + let work_path = PathBuf::from(working_dir); + if self.dir.is_none() { + self.dir = Some(work_path.join("cache").to_string_lossy().to_string()); + } else { + let mut cache_dir = self.dir.as_ref().unwrap().to_string(); + if PathBuf::from(&cache_dir).is_relative() { + cache_dir = work_path.join(&cache_dir).clean().to_string_lossy().to_string(); + } + self.dir = Some(cache_dir.to_string()); + } + + if let Some(val) = self.size.as_ref() { + match parse_size_base_2(val) { + Ok(size) => { + if let Err(err) = usize::try_from(size) { + return Err(info_err!(format!("Cache size could not be determined: {err}"))); + } + } + Err(err) => { + return Err(info_err!(format!("Failed to read cache size: {err}"))) + } + } + } + } + Ok(()) + } +} diff --git a/shared/src/model/config/epg.rs b/shared/src/model/config/epg.rs index 00d1a71d6..6b6a3b4b0 100644 --- a/shared/src/model/config/epg.rs +++ b/shared/src/model/config/epg.rs @@ -1,3 +1,6 @@ +use crate::{info_err}; +use crate::error::{TuliproxError, TuliproxErrorKind}; +use crate::model::EpgSmartMatchConfigDto; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] @@ -9,35 +12,17 @@ pub struct EpgSourceDto { pub logo_override: bool, } +impl EpgSourceDto { + pub fn prepare(&mut self) { + self.url = self.url.trim().to_string(); + } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "lowercase")] -pub enum EpgNamePrefix { - #[default] - Ignore, - Suffix(String), - Prefix(String), + pub fn is_valid(&self) -> bool { + !self.url.is_empty() + } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -pub struct EpgSmartMatchConfigDto { - #[serde(default)] - pub enabled: bool, - pub normalize_regex: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub strip: Option>, - #[serde(default)] - pub name_prefix: EpgNamePrefix, - #[serde(default)] - pub name_prefix_separator: Option>, - #[serde(default)] - pub fuzzy_matching: bool, - #[serde(default)] - pub match_threshold: u16, - #[serde(default)] - pub best_match_threshold: u16, -} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] @@ -46,4 +31,44 @@ pub struct EpgConfigDto { pub sources: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub smart_match: Option, + #[serde(skip)] + pub t_sources: Vec, } + +impl EpgConfigDto { + pub fn prepare(&mut self, create_auto_url: F, include_computed: bool) -> Result<(), TuliproxError> + where + F: Fn() -> Result, + { + if include_computed { + self.t_sources = Vec::new(); + if let Some(epg_sources) = self.sources.as_mut() { + for epg_source in epg_sources { + epg_source.prepare(); + if epg_source.is_valid() { + if include_computed && epg_source.url.eq_ignore_ascii_case("auto") { + let auto_url = create_auto_url(); + match auto_url { + Ok(provider_url) => { + self.t_sources.push(EpgSourceDto { + url: provider_url, + priority: epg_source.priority, + logo_override: epg_source.logo_override, + }); + } + Err(err) => return Err(info_err!(err)) + } + } else { + self.t_sources.push(epg_source.clone()); + } + } + } + } + + if let Some(smart_match) = self.smart_match.as_mut() { + smart_match.prepare()?; + } + } + Ok(()) + } +} \ No newline at end of file diff --git a/shared/src/model/config/epg_smart_match.rs b/shared/src/model/config/epg_smart_match.rs new file mode 100644 index 000000000..79d451361 --- /dev/null +++ b/shared/src/model/config/epg_smart_match.rs @@ -0,0 +1,72 @@ +use log::warn; +use crate::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum EpgNamePrefix { + #[default] + Ignore, + Suffix(String), + Prefix(String), +} + +#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EpgSmartMatchConfigDto { + #[serde(default)] + pub enabled: bool, + pub normalize_regex: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub strip: Option>, + #[serde(default)] + pub name_prefix: EpgNamePrefix, + #[serde(default)] + pub name_prefix_separator: Option>, + #[serde(default)] + pub fuzzy_matching: bool, + #[serde(default)] + pub match_threshold: u16, + #[serde(default)] + pub best_match_threshold: u16, +} + +impl EpgSmartMatchConfigDto { + + /// # Panics + /// + /// Prepares the EPG smart match configuration by validating thresholds, compiling normalization regex, and setting default values as needed. + /// + /// Adjusts match thresholds to valid ranges, compiles the normalization regex, and sets default strip values and name prefix separators if not provided. Returns an error if the normalization regex is invalid. + /// + /// # Returns + /// + /// `Ok(())` if preparation succeeds, or an `TuliproxError` if regex compilation fails. + pub fn prepare(&mut self) -> Result<(), TuliproxError> { + if !self.enabled { + return Ok(()); + } + + if self.match_threshold == 0 { + self.match_threshold = 80; + } else if self.match_threshold < 10 { + warn!("the match_threshold is less than 10%, set to 10%"); + self.match_threshold = 10; + } else if self.match_threshold > 100 { + warn!("the match_threshold is more than 100%, set to 80%"); + self.match_threshold = 100; + } + + if self.best_match_threshold == 0 || self.best_match_threshold > 100 || self.best_match_threshold < self.match_threshold { + self.best_match_threshold = 99; + } + + if let Some(regstr) = self.normalize_regex.as_ref() { + let re = regex::Regex::new(regstr.as_str()); + if re.is_err() { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "cant parse regex: {}", regstr); + } + }; + + Ok(()) + } +} \ No newline at end of file diff --git a/shared/src/model/config/hdhomerun.rs b/shared/src/model/config/hdhomerun.rs index 7807935dd..aba75c2c8 100644 --- a/shared/src/model/config/hdhomerun.rs +++ b/shared/src/model/config/hdhomerun.rs @@ -1,4 +1,8 @@ +use std::collections::HashSet; use enum_iterator::Sequence; +use log::warn; +use crate::create_tuliprox_error_result; +use crate::error::{TuliproxError, TuliproxErrorKind}; use crate::model::TargetType; fn default_friendly_name() -> String { String::from("TuliproxTV") } @@ -57,6 +61,28 @@ pub struct HdHomeRunDeviceConfigDto { pub tuner_count: u8, } +impl HdHomeRunDeviceConfigDto { + pub fn prepare(&mut self, device_num: u8) { + self.name = self.name.trim().to_string(); + if self.name.is_empty() { + self.name = format!("device{device_num}"); + warn!("Device name empty, assigned new name: {}", self.name); + } + + if self.tuner_count == 0 { + self.tuner_count = 1; + } + + if device_num > 0 && self.friendly_name == default_friendly_name() { + self.friendly_name = format!("{} {}", self.friendly_name, device_num); + } + if self.device_udn == default_device_udn() { + self.device_udn = format!("{}:{}", self.device_udn, device_num+1); + } + } +} + + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct HdHomeRunConfigDto { @@ -66,3 +92,34 @@ pub struct HdHomeRunConfigDto { pub auth: bool, pub devices: Vec, } + +impl HdHomeRunConfigDto { + pub fn prepare(&mut self, api_port: u16) -> Result<(), TuliproxError> { + let mut names = HashSet::new(); + let mut ports = HashSet::new(); + ports.insert(api_port); + for (device_num, device) in (0_u8..).zip(self.devices.iter_mut()) { + device.prepare(device_num); + if names.contains(&device.name) { + names.insert(&device.name); + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "HdHomeRun duplicate device name {}", device.name); + } + if device.port > 0 && ports.contains(&device.port) { + ports.insert(device.port); + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "HdHomeRun duplicate port {}", device.port); + } + } + let mut current_port = api_port + 1; + for device in &mut self.devices { + if device.port == 0 { + while ports.contains(¤t_port) { + current_port += 1; + } + device.port = current_port; + current_port += 1; + } + } + + Ok(()) + } +} \ No newline at end of file diff --git a/shared/src/model/config/input.rs b/shared/src/model/config/input.rs index 839544f19..d1c649868 100644 --- a/shared/src/model/config/input.rs +++ b/shared/src/model/config/input.rs @@ -1,7 +1,34 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::fmt::Display; +use std::str::FromStr; use enum_iterator::Sequence; +use crate::{create_tuliprox_error_result, handle_tuliprox_error_result_list, info_err}; +use crate::error::{TuliproxError, TuliproxErrorKind}; use crate::model::{EpgConfigDto}; -use crate::utils::{default_as_true}; +use crate::utils::{default_as_true, get_base_url_from_str, get_credentials_from_url_str, get_trimmed_string, sanitize_sensitive_info}; +use log::debug; + +macro_rules! check_input_credentials { + ($this:ident, $input_type:expr) => { + match $input_type { + InputType::M3u | InputType::M3uBatch => { + if $this.username.is_some() || $this.password.is_some() { + debug!("for input type m3u: username and password are ignored"); + } + if $this.username.is_none() && $this.password.is_none() { + let (username, password) = get_credentials_from_url_str(&$this.url); + $this.username = username; + $this.password = password; + } + } + InputType::Xtream | InputType::XtreamBatch => { + if $this.username.is_none() || $this.password.is_none() { + return Err(info_err!("for input type xtream: username and password are mandatory".to_string())); + } + } + } + }; +} #[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)] @@ -17,6 +44,79 @@ pub enum InputType { XtreamBatch, } + +impl InputType { + const M3U: &'static str = "m3u"; + const XTREAM: &'static str = "xtream"; + const M3U_BATCH: &'static str = "m3u_batch"; + const XTREAM_BATCH: &'static str = "xtream_batch"; +} + +impl Display for InputType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", match self { + Self::M3u => Self::M3U, + Self::Xtream => Self::XTREAM, + Self::M3uBatch => Self::M3U_BATCH, + Self::XtreamBatch => Self::XTREAM_BATCH, + }) + } +} + +impl FromStr for InputType { + type Err = TuliproxError; + + fn from_str(s: &str) -> Result { + if s.eq(Self::M3U) { + Ok(Self::M3u) + } else if s.eq(Self::XTREAM) { + Ok(Self::Xtream) + } else if s.eq(Self::M3U_BATCH) { + Ok(Self::M3uBatch) + } else if s.eq(Self::XTREAM_BATCH) { + Ok(Self::XtreamBatch) + } else { + create_tuliprox_error_result!(TuliproxErrorKind::Info, "Unknown InputType: {}", s) + } + } +} + +#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)] +pub enum InputFetchMethod { + #[default] + GET, + POST, +} + +impl InputFetchMethod { + const GET_METHOD: &'static str = "GET"; + const POST_METHOD: &'static str = "POST"; +} + +impl Display for InputFetchMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", match self { + Self::GET => Self::GET_METHOD, + Self::POST => Self::POST_METHOD, + }) + } +} + +impl FromStr for InputFetchMethod { + type Err = TuliproxError; + + fn from_str(s: &str) -> Result { + if s.eq(Self::GET_METHOD) { + Ok(Self::GET) + } else if s.eq(Self::POST_METHOD) { + Ok(Self::POST) + } else { + create_tuliprox_error_result!(TuliproxErrorKind::Info, "Unknown Fetch Method: {}", s) + } + } +} + + #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] #[serde(deny_unknown_fields)] @@ -49,14 +149,26 @@ pub struct ConfigInputAliasDto { pub max_connections: u16, } -#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, - PartialEq, Eq, Default)] -pub enum InputFetchMethod { - #[default] - GET, - POST, +impl ConfigInputAliasDto { + pub fn prepare(&mut self, index: u16, input_type: &InputType) -> Result<(), TuliproxError> { + self.id = index; + self.name = self.name.trim().to_string(); + if self.name.is_empty() { + return Err(info_err!("name for input is mandatory".to_string())); + } + self.url = self.url.trim().to_string(); + if self.url.is_empty() { + return Err(info_err!("url for input is mandatory".to_string())); + } + self.username = get_trimmed_string(&self.username); + self.password = get_trimmed_string(&self.password); + check_input_credentials!(self, input_type); + + Ok(()) + } } + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct ConfigInputDto { @@ -89,3 +201,68 @@ pub struct ConfigInputDto { #[serde(default)] pub method: InputFetchMethod, } + +impl ConfigInputDto { + #[allow(clippy::cast_possible_truncation)] + pub fn prepare(&mut self, index: u16, include_computed: bool) -> Result { + self.id = index; + self.check_url()?; + + self.name = self.name.trim().to_string(); + if self.name.is_empty() { + return Err(info_err!("name for input is mandatory".to_string())); + } + + self.username = get_trimmed_string(&self.username); + self.password = get_trimmed_string(&self.password); + check_input_credentials!(self, self.input_type); + self.persist = get_trimmed_string(&self.persist); + + if let Some(epg) = self.epg.as_mut() { + let create_auto_url = || { + let (username, password) = if self.username.is_none() || self.password.is_none() { + get_credentials_from_url_str(&self.url) + } else { + (self.username.clone(), self.password.clone()) + }; + + if username.is_none() || password.is_none() { + Err(format!("auto_epg is enabled for input {}, but no credentials could be extracted", self.name)) + } else { + let base_url = get_base_url_from_str(&self.url); + if base_url.is_some() { + let provider_epg_url = format!("{}/xmltv.php?username={}&password={}", base_url.unwrap_or_default(), username.unwrap_or_default(), password.unwrap_or_default()); + Ok(provider_epg_url) + } else { + Err(format!("auto_epg is enabled for input {}, but url could not be parsed {}", self.name, sanitize_sensitive_info(&self.url))) + } + } + }; + + epg.prepare(create_auto_url, include_computed)?; + epg.t_sources = { + let mut seen_urls = HashSet::new(); + epg.t_sources + .drain(..) + .filter(|src| seen_urls.insert(src.url.clone())) + .collect() + }; + } + + if let Some(aliases) = self.aliases.as_mut() { + let input_type = &self.input_type; + handle_tuliprox_error_result_list!(TuliproxErrorKind::Info, aliases.iter_mut().enumerate().map(|(idx, i)| i.prepare(index+1+(idx as u16), input_type))); + } + Ok(index + self.aliases.as_ref().map_or(0, std::vec::Vec::len) as u16) + } + + fn check_url(&mut self) -> Result<(), TuliproxError> { + self.url = self.url.trim().to_string(); + if self.url.is_empty() { + return Err(info_err!("url for input is mandatory".to_string())); + } + Ok(()) + } + + +} \ No newline at end of file diff --git a/shared/src/model/config/ipcheck.rs b/shared/src/model/config/ipcheck.rs index 61456191c..cfd1ca79c 100644 --- a/shared/src/model/config/ipcheck.rs +++ b/shared/src/model/config/ipcheck.rs @@ -1,3 +1,6 @@ +use regex::Regex; +use crate::error::{TuliproxError, TuliproxErrorKind}; + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct IpCheckConfigDto { @@ -21,4 +24,50 @@ pub struct IpCheckConfigDto { #[serde(default, skip_serializing_if = "Option::is_none")] pub pattern_ipv6: Option, -} \ No newline at end of file +} + +impl IpCheckConfigDto { + pub fn prepare(&mut self) -> Result<(), TuliproxError> { + if let Some(url) = &self.url { + let url = url.trim(); + if url.is_empty() { + self.url = None; + } else { + self.url = Some(url.to_owned()); + } + } + if let Some(url) = &self.url_ipv4 { + let url = url.trim(); + if url.is_empty() { + self.url_ipv4 = None; + } else { + self.url_ipv4 = Some(url.to_owned()); + } + } + if let Some(url) = &self.url_ipv6 { + let url = url.trim(); + if url.is_empty() { + self.url_ipv6 = None; + } else { + self.url_ipv6 = Some(url.to_owned()); + } + } + + if self.url.is_none() && self.url_ipv4.is_none() && self.url_ipv6.is_none() { + return Err(TuliproxError::new(TuliproxErrorKind::Info, "No url provided!".to_owned())); + } + + // TODO allow or do not allow ? + // if self.url.is_some() && (self.url_ipv4.is_some() || self.url_ipv6.is_some()) { + // return Err(TuliproxError::new(TuliproxErrorKind::Info, "url in combination with ipv4 and/or ipv6 url not allowed!".to_owned())); + // } + + if let Some(p4) = &self.pattern_ipv4 { + Regex::new(p4).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, format!("Invalid IPv4 regex: {p4} {err}")))?; + } + if let Some(p6) = &self.pattern_ipv6 { + Regex::new(p6).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, format!("Invalid IPv6 regex: {p6} {err}")))?; + } + Ok(()) + } +} diff --git a/shared/src/model/config/mod.rs b/shared/src/model/config/mod.rs index 952db41ad..7566a3249 100644 --- a/shared/src/model/config/mod.rs +++ b/shared/src/model/config/mod.rs @@ -1,6 +1,7 @@ mod trakt; mod base; -mod web; +mod web_ui; +mod web_auth; mod messaging; mod hdhomerun; mod ipcheck; @@ -20,11 +21,16 @@ mod proxy; mod rename; mod api_proxy; mod api_user; +mod api; +mod epg_smart_match; +mod pattern_template; pub use base::*; +pub use api::*; pub use api_proxy::*; pub use api_user::*; -pub use web::*; +pub use web_auth::*; +pub use web_ui::*; pub use messaging::*; pub use hdhomerun::*; pub use ipcheck::*; @@ -36,6 +42,7 @@ pub use schedule::*; pub use log::*; pub use input::*; pub use stream::*; +pub use epg_smart_match::*; pub use epg::*; pub use cache::*; pub use rate_limit::*; @@ -43,3 +50,4 @@ pub use reverse_proxy::*; pub use proxy::*; pub use trakt::*; pub use rename::*; +pub use pattern_template::*; \ No newline at end of file diff --git a/shared/src/model/config/pattern_template.rs b/shared/src/model/config/pattern_template.rs new file mode 100644 index 000000000..5e2ced8a4 --- /dev/null +++ b/shared/src/model/config/pattern_template.rs @@ -0,0 +1,26 @@ + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(untagged)] +pub enum TemplateValue { + Single(String), + Multi(Vec), +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PatternTemplate { + pub name: String, + pub value: TemplateValue, + #[serde(skip)] + pub placeholder: String, +} + +impl PatternTemplate { + pub fn prepare(&mut self) { + let mut placeholder = String::with_capacity(self.name.len() + 2); + placeholder.push('!'); + placeholder.push_str(&self.name); + placeholder.push('!'); + + self.placeholder = placeholder; + } +} \ No newline at end of file diff --git a/shared/src/model/config/proxy.rs b/shared/src/model/config/proxy.rs index 8832eb777..43d502316 100644 --- a/shared/src/model/config/proxy.rs +++ b/shared/src/model/config/proxy.rs @@ -1,3 +1,4 @@ +use crate::error::{TuliproxError, TuliproxErrorKind}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] #[serde(deny_unknown_fields)] @@ -6,3 +7,27 @@ pub struct ProxyConfigDto { pub username: Option, pub password: Option, } + +impl ProxyConfigDto { + pub fn prepare(&mut self) -> Result<(), TuliproxError> { + if self.username.is_some() || self.password.is_some() { + if let (Some(username), Some(password)) = (self.username.as_ref(), self.password.as_ref()) { + let uname = username.trim(); + let pwd = password.trim(); + if uname.is_empty() || pwd.is_empty() { + return Err(TuliproxError::new(TuliproxErrorKind::Info, "Proxy credentials missing".to_string())); + } + self.username = Some(uname.to_string()); + self.password = Some(pwd.to_string()); + } else { + return Err(TuliproxError::new(TuliproxErrorKind::Info, "Proxy credentials missing".to_string())); + } + } + + self.url = self.url.trim().to_string(); + if self.url.is_empty() { + return Err(TuliproxError::new(TuliproxErrorKind::Info, "Proxy url missing".to_string())); + } + Ok(()) + } +} \ No newline at end of file diff --git a/shared/src/model/config/rate_limit.rs b/shared/src/model/config/rate_limit.rs index 4df7ae8f5..524bc0391 100644 --- a/shared/src/model/config/rate_limit.rs +++ b/shared/src/model/config/rate_limit.rs @@ -1,7 +1,21 @@ +use crate::error::{TuliproxError, TuliproxErrorKind}; + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct RateLimitConfigDto { pub enabled: bool, pub period_millis: u64, pub burst_size: u32, +} + +impl RateLimitConfigDto { + pub(crate) fn prepare(&self) -> Result<(), TuliproxError> { + if self.period_millis == 0 { + return Err(TuliproxError::new(TuliproxErrorKind::Info, "Rate limiter period can't be 0".to_string())); + } + if self.burst_size == 0 { + return Err(TuliproxError::new(TuliproxErrorKind::Info, "Rate limiter burst can't be 0".to_string())); + } + Ok(()) + } } \ No newline at end of file diff --git a/shared/src/model/config/rename.rs b/shared/src/model/config/rename.rs index a22f53f09..8560ba6ab 100644 --- a/shared/src/model/config/rename.rs +++ b/shared/src/model/config/rename.rs @@ -1,4 +1,6 @@ -use crate::model::ItemField; +use crate::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind}; +use crate::foundation::filter::apply_templates_to_pattern_single; +use crate::model::{ItemField, PatternTemplate}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] @@ -6,4 +8,14 @@ pub struct ConfigRenameDto { pub field: ItemField, pub pattern: String, pub new_name: String, +} + +impl ConfigRenameDto { + pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { + self.pattern = apply_templates_to_pattern_single(&self.pattern, templates)?; + if let Err(err) = regex::Regex::new(&self.pattern) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "cant parse regex: {} {err}", &self.pattern); + } + Ok(()) + } } \ No newline at end of file diff --git a/shared/src/model/config/reverse_proxy.rs b/shared/src/model/config/reverse_proxy.rs index 03fe2a150..2e3a97e00 100644 --- a/shared/src/model/config/reverse_proxy.rs +++ b/shared/src/model/config/reverse_proxy.rs @@ -1,16 +1,40 @@ +use log::warn; +use crate::error::TuliproxError; use crate::model::{CacheConfigDto, RateLimitConfigDto, StreamConfigDto}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct ReverseProxyConfigDto { + #[serde(default)] + pub resource_rewrite_disabled: bool, + #[serde(default)] + pub disable_referer_header: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub stream: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cache: Option, - #[serde(default)] - pub resource_rewrite_disabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub rate_limit: Option, - #[serde(default)] - pub disable_referer_header: bool, +} + +impl ReverseProxyConfigDto { + pub(crate) fn prepare(&mut self, working_dir: &str) -> Result<(), TuliproxError> { + if let Some(stream) = self.stream.as_mut() { + stream.prepare()?; + } + if let Some(cache) = self.cache.as_mut() { + if cache.enabled && self.resource_rewrite_disabled { + warn!("The cache is disabled because resource rewrite is disabled"); + cache.enabled = false; + } + cache.prepare(working_dir)?; + } + + if let Some(rate_limit) = self.rate_limit.as_mut() { + if rate_limit.enabled { + rate_limit.prepare()?; + } + } + Ok(()) + } } diff --git a/shared/src/model/config/sort.rs b/shared/src/model/config/sort.rs index d34291185..d9330069f 100644 --- a/shared/src/model/config/sort.rs +++ b/shared/src/model/config/sort.rs @@ -1,11 +1,19 @@ -use crate::model::ItemField; +use regex::Regex; +use crate::{create_tuliprox_error, handle_tuliprox_error_result_list}; +use crate::error::{TuliproxError, TuliproxErrorKind}; +use crate::foundation::filter::{apply_templates_to_pattern, apply_templates_to_pattern_single}; +use crate::model::{ItemField, PatternTemplate, TemplateValue}; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ConfigSortGroupDto { - pub order: SortOrder, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sequence: Option>, +fn compile_regex_vec(patterns: Option<&Vec>) -> Result>, TuliproxError> { + patterns.as_ref() + .map(|seq| { + seq.iter() + .map(|s| Regex::new(s).map_err(|err| { + create_tuliprox_error!(TuliproxErrorKind::Info, "cant parse regex: {s} {err}") + })) + .collect::, _>>() + }) + .transpose() // convert Option> to Result> } #[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize)] @@ -16,6 +24,39 @@ pub enum SortOrder { Desc, } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConfigSortGroupDto { + pub order: SortOrder, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sequence: Option>, + #[serde(skip)] + pub t_sequence: Option>, +} + +impl ConfigSortGroupDto { + + pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { + let processed_sequence = match (&self.sequence, templates) { + (Some(seqs), Some(_templs)) => { + let mut result = Vec::new(); + for s in seqs { + match apply_templates_to_pattern(s, templates, true)? { + TemplateValue::Single(val) => result.push(val), + TemplateValue::Multi(vals) => result.extend(vals), + } + } + Some(result) + }, + (Some(seqs), None) => Some(seqs.clone()), + (None, _) => None, + }; + + self.t_sequence = compile_regex_vec(processed_sequence.as_ref())?; + Ok(()) + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] pub struct ConfigSortChannelDto { @@ -26,6 +67,38 @@ pub struct ConfigSortChannelDto { pub order: SortOrder, #[serde(default, skip_serializing_if = "Option::is_none")] pub sequence: Option>, + #[serde(skip)] + pub t_sequence: Option>, +} + +impl ConfigSortChannelDto { + pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { + self.group_pattern = apply_templates_to_pattern_single(&self.group_pattern, templates)?; + // Compile group_pattern + Regex::new(&self.group_pattern).map_err(|err| { + create_tuliprox_error!(TuliproxErrorKind::Info, "cant parse regex: {} {err}", &self.group_pattern) + })?; + + // Transform sequence with templates if provided, otherwise use raw sequence + let processed_sequence = match (&self.sequence, templates) { + (Some(seqs), Some(tmpls)) => { + let mut result = Vec::new(); + for s in seqs { + match apply_templates_to_pattern(s, Some(tmpls), true)? { + TemplateValue::Single(val) => result.push(val), + TemplateValue::Multi(vals) => result.extend(vals), + } + } + Some(result) + }, + (Some(seqs), None) => Some(seqs.clone()), + (None, _) => None, + }; + + // Compile regex patterns + self.t_sequence = compile_regex_vec(processed_sequence.as_ref())?; + Ok(()) + } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] @@ -38,3 +111,16 @@ pub struct ConfigSortDto { #[serde(default, skip_serializing_if = "Option::is_none")] pub channels: Option>, } + + +impl ConfigSortDto { + pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { + if let Some(group) = self.groups.as_mut() { + group.prepare(templates)?; + } + if let Some(channels) = self.channels.as_mut() { + handle_tuliprox_error_result_list!(TuliproxErrorKind::Info, channels.iter_mut().map(|csc| csc.prepare(templates))); + } + Ok(()) + } +} \ No newline at end of file diff --git a/shared/src/model/config/source.rs b/shared/src/model/config/source.rs index 5ea4f181b..c49370d28 100644 --- a/shared/src/model/config/source.rs +++ b/shared/src/model/config/source.rs @@ -1,21 +1,10 @@ -use crate::model::{ConfigInputDto}; +use std::collections::HashSet; +use crate::create_tuliprox_error_result; +use crate::error::{handle_tuliprox_error_result_list, TuliproxError, TuliproxErrorKind}; +use crate::foundation::filter::prepare_templates; +use crate::model::{ConfigInputDto, PatternTemplate}; use crate::model::config::target::ConfigTargetDto; - - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(untagged)] -pub enum TemplateValue { - Single(String), - Multi(Vec), -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct PatternTemplateDto { - pub name: String, - pub value: TemplateValue, - #[serde(skip)] - pub placeholder: String, -} +use crate::utils::default_as_default; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] @@ -24,10 +13,79 @@ pub struct ConfigSourceDto { pub targets: Vec, } +impl ConfigSourceDto { + #[allow(clippy::cast_possible_truncation)] + pub fn prepare(&mut self, index: u16, include_computed: bool) -> Result { + handle_tuliprox_error_result_list!(TuliproxErrorKind::Info, self.inputs.iter_mut().enumerate() + .map(|(idx, i)| i.prepare(index+(idx as u16), include_computed))); + Ok(index + (self.inputs.len() as u16)) + } +} + #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] pub struct SourcesConfigDto { #[serde(default, skip_serializing_if = "Option::is_none")] - pub templates: Option>, + pub templates: Option>, pub sources: Vec, +} + +impl SourcesConfigDto { + pub fn prepare(&mut self, include_computed: bool) -> Result<(), TuliproxError> { + self.prepare_templates()?; + self.prepare_sources(include_computed)?; + self.check_unique_target_names()?; + Ok(()) + } + + fn prepare_sources(&mut self, include_computed: bool) -> Result<(), TuliproxError> { + // prepare sources and set id's + let mut source_index: u16 = 1; + let mut target_index: u16 = 1; + for source in &mut self.sources { + source_index = source.prepare(source_index, include_computed)?; + for target in &mut source.targets { + // prepare target templates + let prepare_result = match &self.templates { + Some(templ) => target.prepare(target_index, Some(templ)), + _ => target.prepare(target_index, None) + }; + prepare_result?; + target_index += 1; + } + } + Ok(()) + } + + fn prepare_templates(&mut self) -> Result<(), TuliproxError> { + if let Some(templates) = &mut self.templates { + match prepare_templates(templates) { + Ok(tmplts) => { + self.templates = Some(tmplts); + } + Err(err) => { + return Err(err); + } + } + } + Ok(()) + } + + fn check_unique_target_names(&self) -> Result<(), TuliproxError> { + let mut seen_names = HashSet::new(); + let default_target_name = default_as_default(); + for source in &self.sources { + for target in &source.targets { + // check the target name is unique + let target_name = target.name.as_str(); + if !default_target_name.eq_ignore_ascii_case(target_name) { + if seen_names.contains(target_name) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "target names should be unique: {target_name}"); + } + seen_names.insert(target_name); + } + } + } + Ok(()) + } } \ No newline at end of file diff --git a/shared/src/model/config/stream.rs b/shared/src/model/config/stream.rs index 41fce620a..3fabdd936 100644 --- a/shared/src/model/config/stream.rs +++ b/shared/src/model/config/stream.rs @@ -1,4 +1,8 @@ -use crate::utils::{default_grace_period_millis, default_grace_period_timeout_secs}; +use crate::error::{TuliproxError, TuliproxErrorKind}; +use crate::info_err; +use crate::utils::{default_grace_period_millis, default_grace_period_timeout_secs, parse_to_kbps}; + +const STREAM_QUEUE_SIZE: usize = 1024; // mpsc channel holding messages. with 8192byte chunks and 2Mbit/s approx 8MB #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] #[serde(deny_unknown_fields)] @@ -9,6 +13,15 @@ pub struct StreamBufferConfigDto { pub size: usize, } + +impl StreamBufferConfigDto { + fn prepare(&mut self) { + if self.enabled && self.size == 0 { + self.size = STREAM_QUEUE_SIZE; + } + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct StreamConfigDto { @@ -27,3 +40,25 @@ pub struct StreamConfigDto { #[serde(default, skip)] pub throttle_kbps: u64, } + +impl StreamConfigDto { + pub(crate) fn prepare(&mut self) -> Result<(), TuliproxError> { + if let Some(buffer) = self.buffer.as_mut() { + buffer.prepare(); + } + if let Some(throttle) = &self.throttle { + parse_to_kbps(throttle).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, err))?; + } + + if self.grace_period_millis > 0 { + if self.grace_period_timeout_secs == 0 { + let triple_ms = self.grace_period_millis.saturating_mul(3); + self.grace_period_timeout_secs = std::cmp::max(1, triple_ms.div_ceil(1000)); + } else if self.grace_period_millis / 1000 > self.grace_period_timeout_secs { + return Err(info_err!(format!("Grace time period timeout {} sec should be more than grace time period {} ms", self.grace_period_timeout_secs, self.grace_period_millis))); + } + } + + Ok(()) + } +} diff --git a/shared/src/model/config/target.rs b/shared/src/model/config/target.rs index 1d89c7e5b..0c035740d 100644 --- a/shared/src/model/config/target.rs +++ b/shared/src/model/config/target.rs @@ -1,8 +1,11 @@ -use crate::model::{ClusterFlags, ConfigRenameDto, ConfigSortDto, ProcessingOrder, StrmExportStyle, TargetType, TraktConfigDto}; +use crate::error::{TuliproxError, TuliproxErrorKind}; +use crate::{create_tuliprox_error_result, handle_tuliprox_error_result_list, info_err}; +use crate::foundation::filter::{get_filter, Filter}; +use crate::model::{ClusterFlags, ConfigRenameDto, ConfigSortDto, PatternTemplate, ProcessingOrder, StrmExportStyle, TargetType, TraktConfigDto}; use crate::utils::{default_as_true, default_resolve_delay_secs, default_as_default}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] #[serde(deny_unknown_fields)] -pub struct ConfigTargetOptionsDto { +pub struct ConfigTargetOptions { #[serde(default)] pub ignore_logo: bool, #[serde(default)] @@ -35,6 +38,14 @@ pub struct XtreamTargetOutputDto { pub trakt: Option, } +impl XtreamTargetOutputDto { + pub fn prepare(&mut self) { + if let Some(trakt) = &mut self.trakt { + trakt.prepare(); + } + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] pub struct M3uTargetOutputDto { @@ -81,6 +92,16 @@ pub enum TargetOutputDto { HdHomeRun(HdHomeRunTargetOutputDto), } +impl TargetOutputDto { + pub fn prepare(&mut self) { + match self { + TargetOutputDto::Xtream(output) => output.prepare(), + TargetOutputDto::M3u(_) + | TargetOutputDto::Strm(_) + | TargetOutputDto::HdHomeRun(_) => {} + } + } +} #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] #[serde(deny_unknown_fields)] @@ -92,7 +113,7 @@ pub struct ConfigTargetDto { #[serde(default = "default_as_default")] pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub options: Option, + pub options: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub sort: Option, pub filter: String, @@ -106,6 +127,133 @@ pub struct ConfigTargetDto { pub processing_order: ProcessingOrder, #[serde(default, skip_serializing_if = "Option::is_none")] pub watch: Option>, + #[serde(skip)] + pub t_filter: Option, } +impl ConfigTargetDto { + #[allow(clippy::too_many_lines)] + pub fn prepare(&mut self, id: u16, templates: Option<&Vec>) -> Result<(), TuliproxError> { + self.id = id; + if self.output.is_empty() { + return Err(info_err!(format!("Missing output format for {}", self.name))); + } + self.name = self.name.trim().to_string(); + if self.name.is_empty() { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "target name required"); + } + let mut m3u_cnt = 0; + let mut xtream_cnt = 0; + let mut strm_cnt = 0; + let mut strm_needs_xtream = false; + let mut hdhr_cnt = 0; + let mut hdhomerun_needs_m3u = false; + let mut hdhomerun_needs_xtream = false; + + let mut strm_export_styles = vec![]; + let mut strm_directories: Vec<&str> = vec![]; + + for target_output in &mut self.output { + target_output.prepare(); + match target_output { + TargetOutputDto::Xtream(_) => { + xtream_cnt += 1; + if default_as_default().eq_ignore_ascii_case(&self.name) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "unique target name is required for xtream type output: {}", self.name); + } + } + TargetOutputDto::M3u(m3u_output) => { + m3u_cnt += 1; + m3u_output.filename = m3u_output.filename.as_ref().map(|s| s.trim().to_string()); + } + TargetOutputDto::Strm(strm_output) => { + strm_cnt += 1; + strm_output.directory = strm_output.directory.trim().to_string(); + if strm_output.directory.trim().is_empty() { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "directory is required for strm type: {}", self.name); + } + if let Some(username) = &mut strm_output.username { + *username = username.trim().to_string(); + } + let has_username = strm_output.username.as_ref().is_some_and(|u| !u.is_empty()); + + if has_username { + strm_needs_xtream = true; + } + if strm_export_styles.contains(&strm_output.style) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "strm outputs with same export style are not allowed: {}", self.name); + } + strm_export_styles.push(strm_output.style); + if strm_directories.contains(&strm_output.directory.as_str()) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "strm outputs with same export directory are not allowed: {}", self.name); + } + strm_directories.push(strm_output.directory.as_str()); + } + TargetOutputDto::HdHomeRun(hdhomerun_output) => { + hdhr_cnt += 1; + hdhomerun_output.username = hdhomerun_output.username.trim().to_string(); + if hdhomerun_output.username.is_empty() { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Username is required for HdHomeRun type: {}", self.name); + } + + hdhomerun_output.device = hdhomerun_output.device.trim().to_string(); + if hdhomerun_output.device.is_empty() { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Device is required for HdHomeRun type: {}", self.name); + } + + if let Some(use_output) = hdhomerun_output.use_output.as_ref() { + match &use_output { + TargetType::M3u => { hdhomerun_needs_m3u = true; } + TargetType::Xtream => { hdhomerun_needs_xtream = true; } + _ => return create_tuliprox_error_result!(TuliproxErrorKind::Info, "HdHomeRun output option `use_output` only accepts `m3u` or `xtream` for target: {}", self.name), + } + } + } + } + } + + if m3u_cnt > 1 || xtream_cnt > 1 || hdhr_cnt > 1 { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Multiple output formats with same type : {}", self.name); + } + + if strm_cnt > 0 && strm_needs_xtream && xtream_cnt == 0 { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "strm output with a username is only permitted when used in combination with xtream output: {}", self.name); + } + + if hdhr_cnt > 0 { + if xtream_cnt == 0 && m3u_cnt == 0 { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "HdHomeRun output is only permitted when used in combination with xtream or m3u output: {}", self.name); + } + if hdhomerun_needs_m3u && m3u_cnt == 0 { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "HdHomeRun output has `use_output=m3u` but no `m3u` output defined: {}", self.name); + } + if hdhomerun_needs_xtream && xtream_cnt == 0 { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "HdHomeRun output has `use_output=xtream` but no `xtream` output defined: {}", self.name); + } + } + + if let Some(watch) = &self.watch { + for pat in watch { + if let Err(err) = regex::Regex::new(pat) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Invalid watch regular expression: {}", err); + } + } + } + + match get_filter(&self.filter, templates) { + Ok(fltr) => { + // debug!("Filter: {}", fltr); + self.t_filter = Some(fltr); + if let Some(renames) = self.rename.as_mut() { + handle_tuliprox_error_result_list!(TuliproxErrorKind::Info, renames.iter_mut().map(|cr|cr.prepare(templates))); + } + if let Some(sort) = self.sort.as_mut() { + sort.prepare(templates)?; + } + Ok(()) + } + Err(err) => Err(err), + } + } +} \ No newline at end of file diff --git a/shared/src/model/config/trakt.rs b/shared/src/model/config/trakt.rs index 92ec4d161..026e32288 100644 --- a/shared/src/model/config/trakt.rs +++ b/shared/src/model/config/trakt.rs @@ -1,10 +1,13 @@ use serde::{Deserialize, Serialize}; +const TRAKT_API_KEY: &str = "0183a05ad97098d87287fe46da4ae286f434f32e8e951caad4cc147c947d79a3"; +const TRAKT_API_VERSION: &str = "2"; +const TRAKT_API_URL: &str = "https://api.trakt.tv"; fn default_fuzzy_threshold() -> u8 { 80 } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum TraktContentType { Vod, @@ -22,13 +25,25 @@ impl Default for TraktContentType { #[serde(deny_unknown_fields)] pub struct TraktApiConfigDto { #[serde(default)] - pub(crate) key: String, + pub key: String, #[serde(default)] - pub(crate) version: String, + pub version: String, #[serde(default)] - pub(crate) url: String, + pub url: String, } +impl TraktApiConfigDto { + pub fn prepare(&mut self) { + let key = self.key.trim(); + self.key = String::from(if key.is_empty() { TRAKT_API_KEY } else { key }); + let version = self.version.trim(); + self.version = String::from(if version.is_empty() { TRAKT_API_VERSION } else { version }); + let url = self.url.trim(); + self.url = String::from(if url.is_empty() { TRAKT_API_URL } else { url }); + } +} + + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TraktListConfigDto { @@ -47,3 +62,10 @@ pub struct TraktConfigDto { pub api: TraktApiConfigDto, pub lists: Vec, } + + +impl TraktConfigDto { + pub fn prepare(&mut self) { + self.api.prepare(); + } +} diff --git a/shared/src/model/config/video_download.rs b/shared/src/model/config/video_download.rs index d40afc9d1..bcbac7645 100644 --- a/shared/src/model/config/video_download.rs +++ b/shared/src/model/config/video_download.rs @@ -1,4 +1,10 @@ use std::collections::HashMap; +use std::borrow::BorrowMut; +use crate::create_tuliprox_error_result; +use crate::error::{TuliproxError, TuliproxErrorKind}; + + +pub const DEFAULT_USER_AGENT: &str = "VLC/3.0.16 LibVLC/3.0.16"; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] #[serde(deny_unknown_fields)] @@ -23,3 +29,34 @@ pub struct VideoConfigDto { #[serde(default, skip_serializing_if = "Option::is_none")] pub web_search: Option, } + + +impl VideoConfigDto { + /// # Panics + /// + /// Will panic if default `RegEx` gets invalid + pub fn prepare(&mut self) -> Result<(), TuliproxError> { + if self.extensions.is_empty() { + self.extensions = ["mkv", "avi", "mp4", "mpeg", "divx", "mov"] + .iter() + .map(|&arg| arg.to_string()) + .collect(); + } + match &mut self.download { + None => {} + Some(downl) => { + if downl.headers.is_empty() { + downl.headers.borrow_mut().insert("Accept".to_string(), "video/*".to_string()); + downl.headers.borrow_mut().insert("User-Agent".to_string(), DEFAULT_USER_AGENT.to_string()); + } + + if let Some(episode_pattern) = &downl.episode_pattern { + if let Err(err) = regex::Regex::new(episode_pattern) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "cant parse regex: {episode_pattern} {err}"); + } + } + } + } + Ok(()) + } +} \ No newline at end of file diff --git a/shared/src/model/config/web.rs b/shared/src/model/config/web.rs deleted file mode 100644 index 20f40fc15..000000000 --- a/shared/src/model/config/web.rs +++ /dev/null @@ -1,27 +0,0 @@ -use crate::utils::{default_as_true}; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WebAuthConfigDto { - #[serde(default = "default_as_true")] - pub enabled: bool, - pub issuer: String, - pub secret: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub userfile: Option, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -#[serde(deny_unknown_fields)] -pub struct WebUiConfigDto { - #[serde(default = "default_as_true")] - pub enabled: bool, - #[serde(default = "default_as_true")] - pub user_ui_enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub player_server: Option, -} diff --git a/shared/src/model/config/web_auth.rs b/shared/src/model/config/web_auth.rs new file mode 100644 index 000000000..9fb4dfd58 --- /dev/null +++ b/shared/src/model/config/web_auth.rs @@ -0,0 +1,12 @@ +use crate::utils::{default_as_true}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WebAuthConfigDto { + #[serde(default = "default_as_true")] + pub enabled: bool, + pub issuer: String, + pub secret: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub userfile: Option, +} diff --git a/backend/src/model/config/webui.rs b/shared/src/model/config/web_ui.rs similarity index 74% rename from backend/src/model/config/webui.rs rename to shared/src/model/config/web_ui.rs index 2f060168f..153cf016e 100644 --- a/backend/src/model/config/webui.rs +++ b/shared/src/model/config/web_ui.rs @@ -1,6 +1,6 @@ -use shared::utils::default_as_true; -use shared::error::{TuliproxError, TuliproxErrorKind}; -use crate::model::WebAuthConfig; +use crate::error::{TuliproxError, TuliproxErrorKind}; +use crate::model::WebAuthConfigDto; +use crate::utils::{default_as_true}; const RESERVED_PATHS: &[&str] = &[ "live", "movie", "series", "m3u-stream", "healthcheck", "status", @@ -8,9 +8,10 @@ const RESERVED_PATHS: &[&str] = &[ "get.php", "apiget", "m3u", "resource" ]; + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] #[serde(deny_unknown_fields)] -pub struct WebUiConfig { +pub struct WebUiConfigDto { #[serde(default = "default_as_true")] pub enabled: bool, #[serde(default = "default_as_true")] @@ -18,13 +19,13 @@ pub struct WebUiConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth: Option, + pub auth: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub player_server: Option, } -impl WebUiConfig { - pub fn prepare(&mut self, config_path: &str) -> Result<(), TuliproxError> { +impl WebUiConfigDto { + pub fn prepare(&mut self) -> Result<(), TuliproxError> { if !self.enabled { self.auth = None; } @@ -41,14 +42,6 @@ impl WebUiConfig { self.path = Some(web_path); } } - - if let Some(web_auth) = &mut self.auth { - if web_auth.enabled { - web_auth.prepare(config_path)?; - } else { - self.auth = None; - } - } Ok(()) } } \ No newline at end of file diff --git a/shared/src/model/mapping.rs b/shared/src/model/mapping.rs new file mode 100644 index 000000000..4ac2734aa --- /dev/null +++ b/shared/src/model/mapping.rs @@ -0,0 +1,265 @@ +use enum_iterator::Sequence; +use std::fmt::Display; +use std::str::FromStr; +use std::sync::atomic::AtomicU32; +use std::sync::Arc; +use log::{trace}; +use crate::error::{TuliproxError, TuliproxErrorKind, create_tuliprox_error_result}; + +pub const COUNTER_FIELDS: &[&str] = &["name", "title", "caption", "chno"]; + +pub const MAPPER_FIELDS: &[&str] = &[ + "name", "title", "caption", "group", "id", "chno", "logo", + "logo_small", "parent_code", "audio_track", + "time_shift", "rec", "url", "epg_channel_id", "epg_id" +]; + + +#[macro_export] +macro_rules! valid_property { + ($key:expr, $array:expr) => {{ + $array.contains(&$key) + }}; +} +pub use valid_property; +use crate::foundation::filter::{apply_templates_to_pattern_single, get_filter, prepare_templates, Filter}; +use crate::foundation::mapper::MapperScript; +use crate::info_err; +use crate::model::PatternTemplate; + +#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq)] +pub enum CounterModifier { + #[serde(rename = "assign")] + Assign, + #[serde(rename = "suffix")] + Suffix, + #[serde(rename = "prefix")] + Prefix, +} + +impl Default for CounterModifier { + fn default() -> Self { + Self::Assign + } +} + +impl CounterModifier { + const ASSIGN: &'static str = "assign"; + const SUFFIX: &'static str = "suffix"; + const PREFIX: &'static str = "prefix"; +} + +impl Display for CounterModifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", match self { + Self::Assign => Self::ASSIGN, + Self::Suffix => Self::SUFFIX, + Self::Prefix => Self::PREFIX, + }) + } +} + +impl FromStr for CounterModifier { + type Err = TuliproxError; + + fn from_str(s: &str) -> Result { + if s.eq("assign") { + Ok(Self::Assign) + } else if s.eq("suffix") { + Ok(Self::Suffix) + } else if s.eq("prefix") { + Ok(Self::Prefix) + } else { + create_tuliprox_error_result!(TuliproxErrorKind::Info, "Unknown CounterModifier: {}", s) + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] +pub struct MappingCounterDefinition { + pub filter: String, + pub field: String, + #[serde(default)] + pub concat: String, + #[serde(default)] + pub modifier: CounterModifier, + #[serde(default)] + pub value: u32, + #[serde(default)] + pub padding: u8, +} + +#[derive(Debug, Clone)] +pub struct MappingCounter { + pub filter: Filter, + pub field: String, + pub concat: String, + pub modifier: CounterModifier, + pub value: Arc, + pub padding: u8, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(tag = "modifier", rename_all = "snake_case")] +pub enum MapperOperation { + Lowercase { field: String }, + Uppercase { field: String }, + Capitalize { field: String }, + Suffix { field: String, value: String }, + Prefix { field: String, value: String }, + Set { field: String, value: String }, + Copy { field: String, source: String }, +} + +impl MapperOperation { + pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { + match self { + MapperOperation::Lowercase {ref field } + | MapperOperation::Uppercase { ref field } + | MapperOperation::Capitalize { ref field } => { + if !valid_property!(field.as_str(), MAPPER_FIELDS) { + return Err(info_err!(format!("Invalid mapper attribute field {field}"))); + } + } + + MapperOperation::Copy { ref field, ref source } => { + if !valid_property!(field.as_str(), MAPPER_FIELDS) { + return Err(info_err!(format!("Invalid mapper attribute field {field}"))); + } + if !valid_property!(source.as_str(), MAPPER_FIELDS) { + return Err(info_err!(format!("Invalid mapper source field {source}"))); + } + } + + MapperOperation::Suffix { ref field, ref mut value } + | MapperOperation::Prefix { ref field, ref mut value } + | MapperOperation::Set { ref field, ref mut value } => { + if !valid_property!(field.as_str(), MAPPER_FIELDS) { + return Err(info_err!(format!("Invalid mapper attribute field {field}"))); + } + + if templates.is_some() { + *value = apply_templates_to_pattern_single(value, templates)?; + } + } + } + + Ok(()) + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] +pub struct MapperDto { + pub filter: String, + pub script: String, + #[serde(skip_serializing, skip_deserializing)] + pub t_filter: Option, + #[serde(skip_serializing, skip_deserializing)] + pub t_script: Option, +} + +impl MapperDto { + /// # Panics + /// + /// Will panic if default `RegEx` gets invalid + pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { + match get_filter(&self.filter, templates) { + Ok(filter) => self.t_filter = Some(filter), + Err(err) => return Err(err), + } + let script = if templates.is_some() { + apply_templates_to_pattern_single(&self.script, templates)? + } else { + self.script.to_string() + }; + trace!("Mapper script: {script}"); + self.t_script = Some(MapperScript::parse(&script, templates)?); + Ok(()) + } +} + + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] +pub struct MappingDto { + pub id: String, + #[serde(default)] + pub match_as_ascii: bool, + pub mapper: Option>, + pub counter: Option>, + #[serde(skip_serializing, skip_deserializing)] + pub t_counter: Option>, + #[serde(skip_serializing, skip_deserializing)] + pub templates: Option> +} + +impl MappingDto { + pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { + self.templates = templates.map(|t| t.iter().map(PatternTemplate::clone).collect::>()); + if let Some(mapper_list) = &mut self.mapper { + for mapper in mapper_list { + mapper.prepare(templates)?; + } + } + + if let Some(counter_def_list) = &self.counter { + let mut counters = vec![]; + for def in counter_def_list { + if !valid_property!(def.field.as_str(), COUNTER_FIELDS) { + return Err(info_err!(format!("Invalid counter field {}", def.field))); + } + match get_filter(&def.filter, templates) { + Ok(flt) => { + counters.push(MappingCounter { + filter: flt, + field: def.field.clone(), + concat: def.concat.clone(), + modifier: def.modifier, + value: Arc::new(AtomicU32::new(def.value)), + padding: def.padding, + }); + } + Err(e) => return Err(info_err!(e.to_string())) + } + } + self.t_counter = Some(counters); + } + + Ok(()) + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MappingDefinitionDto { + pub templates: Option>, + pub mapping: Vec, +} + +impl MappingDefinitionDto { + pub fn prepare(&mut self) -> Result<(), TuliproxError> { + if let Some(templates) = &mut self.templates { + match prepare_templates(templates) { + Ok(tmplts) => { + self.templates = Some(tmplts); + } + Err(err) => return Err(err), + } + } + for mapping in &mut self.mapping { + let template_list = self.templates.as_ref(); + mapping.prepare(template_list)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MappingsDto { + pub mappings: MappingDefinitionDto, +} + +impl MappingsDto { + pub fn prepare(&mut self) -> Result<(), TuliproxError> { + self.mappings.prepare() + } + +} diff --git a/shared/src/model/mod.rs b/shared/src/model/mod.rs index e1fba712c..455c49642 100644 --- a/shared/src/model/mod.rs +++ b/shared/src/model/mod.rs @@ -6,6 +6,8 @@ mod target_type; mod processing_order; mod config; mod strm_export_style; +mod mapping; +pub mod xtream_const; pub use self::cluster_flags::*; pub use self::playlist::*; @@ -14,4 +16,5 @@ pub use self::item_field::*; pub use self::target_type::*; pub use self::processing_order::*; pub use self::config::*; -pub use self::strm_export_style::*; \ No newline at end of file +pub use self::strm_export_style::*; +pub use self::mapping::*; diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index f35fd8199..96314bcd7 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -2,6 +2,12 @@ use std::borrow::Cow; use std::fmt::{Display, Formatter}; use std::str::FromStr; use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use crate::model::{ConfigTargetOptions, xtream_const}; +use crate::utils::{extract_extension_from_url, generate_playlist_uuid, get_provider_id, get_string_from_serde_value, get_u64_from_serde_value}; + +// https://de.wikipedia.org/wiki/M3U +// https://siptv.eu/howto/playlist.html pub type UUIDType = [u8; 32]; @@ -125,4 +131,513 @@ pub trait PlaylistEntry: Send + Sync { fn get_provider_url(&self) -> String; fn get_uuid(&self) -> UUIDType; fn get_item_type(&self) -> PlaylistItemType; -} \ No newline at end of file +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PlaylistItemHeader { + pub uuid: UUIDType, // calculated + pub id: String, // provider id + pub virtual_id: u32, // virtual id + pub name: String, + pub chno: String, + pub logo: String, + pub logo_small: String, + pub group: String, + pub title: String, + pub parent_code: String, + pub audio_track: String, + pub time_shift: String, + pub rec: String, + pub url: String, + pub epg_channel_id: Option, + pub xtream_cluster: XtreamCluster, + pub additional_properties: Option, + #[serde(default, skip_serializing, skip_deserializing)] + pub item_type: PlaylistItemType, + #[serde(default)] + pub category_id: u32, + pub input_name: String, +} + +impl PlaylistItemHeader { + pub fn gen_uuid(&mut self) { + self.uuid = generate_playlist_uuid(&self.input_name, &self.id, self.item_type, &self.url); + } + pub const fn get_uuid(&self) -> &UUIDType { + &self.uuid + } + + pub fn get_provider_id(&mut self) -> Option { + match get_provider_id(&self.id, &self.url) { + None => None, + Some(newid) => { + self.id = newid.to_string(); + Some(newid) + } + } + } + + pub fn get_additional_property(&self, field: &str) -> Option<&Value> { + self.additional_properties.as_ref().and_then(|v| match v { + Value::Object(map) => { + map.get(field) + } + _ => None, + }) + } + // pub fn get_additional_property_as_u32(&self, field: &str) -> Option { + // match self.get_additional_property(field) { + // Some(value) => get_u32_from_serde_value(value), + // None => None + // } + // } + pub fn get_additional_property_as_u64(&self, field: &str) -> Option { + match self.get_additional_property(field) { + Some(value) => get_u64_from_serde_value(value), + None => None + } + } + + pub fn get_additional_property_as_str(&self, field: &str) -> Option { + match self.get_additional_property(field) { + Some(value) => get_string_from_serde_value(value), + None => None + } + } +} + +macro_rules! to_m3u_non_empty_fields { + ($header:expr, $line:expr, $(($prop:ident, $field:expr)),*;) => { + $( + if !$header.$prop.is_empty() { + $line = format!("{} {}=\"{}\"", $line, $field, $header.$prop); + } + )* + }; +} + +macro_rules! to_m3u_resource_non_empty_fields { + ($header:expr, $url:expr, $line:expr, $(($prop:ident, $field:expr)),*;) => { + $( + if !$header.$prop.is_empty() { + $line = format!("{} {}=\"{}/{}\"", $line, $field, $url, stringify!($prop)); + } + )* + }; +} + +macro_rules! generate_field_accessor_impl_for_playlist_item_header { + ($($prop:ident),*;) => { + impl crate::model::FieldGetAccessor for crate::model::PlaylistItemHeader { + fn get_field(&self, field: &str) -> Option> { + let field = field.to_lowercase(); + match field.as_str() { + $( + stringify!($prop) => Some(Cow::Borrowed(&self.$prop)), + )* + "input" => Some(Cow::Borrowed(self.input_name.as_str())), + "type" => Some(Cow::Owned(self.item_type.to_string())), + "caption" => Some(if self.title.is_empty() { Cow::Borrowed(&self.name) } else { Cow::Borrowed(&self.title) }), + "epg_channel_id" | "epg_id" => self.epg_channel_id.as_ref().map(|s| Cow::Borrowed(s.as_str())), + _ => None, + } + } + } + impl crate::model::FieldSetAccessor for crate::model::PlaylistItemHeader { + fn set_field(&mut self, field: &str, value: &str) -> bool { + let field = field.to_lowercase(); + let val = String::from(value); + match field.as_str() { + $( + stringify!($prop) => { + self.$prop = val; + true + } + )* + "caption" => { + self.title = val.clone(); + self.name = val; + true + } + "epg_channel_id" | "epg_id" => { + self.epg_channel_id = Some(value.to_owned()); + true + } + _ => false, + } + } + } + } +} + +generate_field_accessor_impl_for_playlist_item_header!(id, /*virtual_id,*/ name, chno, logo, logo_small, group, title, parent_code, audio_track, time_shift, rec, url;); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct M3uPlaylistItem { + pub virtual_id: u32, + pub provider_id: String, + pub name: String, + pub chno: String, + pub logo: String, + pub logo_small: String, + pub group: String, + pub title: String, + pub parent_code: String, + pub audio_track: String, + pub time_shift: String, + pub rec: String, + pub url: String, + pub epg_channel_id: Option, + pub input_name: String, + pub item_type: PlaylistItemType, + #[serde(skip)] + pub t_stream_url: String, + #[serde(skip)] + pub t_resource_url: Option, +} + +impl M3uPlaylistItem { + #[allow(clippy::missing_panics_doc)] + pub fn to_m3u(&self, target_options: Option<&ConfigTargetOptions>, rewrite_urls: bool) -> String { + let options = target_options.as_ref(); + let ignore_logo = options.is_some_and(|o| o.ignore_logo); + let mut line = format!("#EXTINF:-1 tvg-id=\"{}\" tvg-name=\"{}\" group-title=\"{}\"", + self.epg_channel_id.as_ref().map_or("", |o| o.as_ref()), + self.name, self.group); + + if !ignore_logo { + if rewrite_urls && self.t_resource_url.is_some() { + to_m3u_resource_non_empty_fields!(self, self.t_resource_url.as_ref().unwrap(), line, (logo, "tvg-logo"), (logo_small, "tvg-logo-small");); + } else { + to_m3u_non_empty_fields!(self, line, (logo, "tvg-logo"), (logo_small, "tvg-logo-small");); + } + } + + to_m3u_non_empty_fields!(self, line, + (chno, "tvg-chno"), + (parent_code, "parent-code"), + (audio_track, "audio-track"), + (time_shift, "timeshift"), + (rec, "tvg-rec");); + + let url = if self.t_stream_url.is_empty() { &self.url } else { &self.t_stream_url }; + format!("{line},{}\n{url}", self.title, ) + } +} + +impl PlaylistEntry for M3uPlaylistItem { + #[inline] + fn get_virtual_id(&self) -> u32 { + self.virtual_id + } + + fn get_provider_id(&self) -> Option { + get_provider_id(&self.provider_id, &self.url) + } + #[inline] + fn get_category_id(&self) -> Option { + None + } + #[inline] + fn get_provider_url(&self) -> String { + self.url.to_string() + } + + fn get_uuid(&self) -> UUIDType { + generate_playlist_uuid(&self.input_name, &self.provider_id, self.item_type, &self.url) + } + + #[inline] + fn get_item_type(&self) -> PlaylistItemType { + self.item_type + } +} + +macro_rules! generate_field_accessor_impl_for_m3u_playlist_item { + ($($prop:ident),*;) => { + impl crate::model::FieldGetAccessor for M3uPlaylistItem { + fn get_field(&self, field: &str) -> Option> { + let field = field.to_lowercase(); + match field.as_str() { + $( + stringify!($prop) => Some(Cow::Borrowed(&self.$prop)), + )* + "caption" => Some(if self.title.is_empty() { Cow::Borrowed(&self.name) } else { Cow::Borrowed(&self.title) }), + "epg_channel_id" | "epg_id" => self.epg_channel_id.as_ref().map(|s| Cow::Borrowed(s.as_str())), + _ => None, + } + } + } + } +} + +generate_field_accessor_impl_for_m3u_playlist_item!(provider_id, name, chno, logo, logo_small, group, title, parent_code, audio_track, time_shift, rec, url;); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XtreamPlaylistItem { + pub virtual_id: u32, + pub provider_id: u32, + pub name: String, + pub logo: String, + pub logo_small: String, + pub group: String, + pub title: String, + pub parent_code: String, + pub rec: String, + pub url: String, + pub epg_channel_id: Option, + pub xtream_cluster: XtreamCluster, + pub additional_properties: Option, + pub item_type: PlaylistItemType, + pub category_id: u32, + pub input_name: String, + pub channel_no: u32, +} + +impl XtreamPlaylistItem { + + pub fn get_additional_property(&self, field: &str) -> Option { + if let Some(json) = self.additional_properties.as_ref() { + if let Ok(Value::Object(props)) = serde_json::from_str(json) { + return props.get(field).cloned(); + } + } + None + } +} + +impl PlaylistEntry for XtreamPlaylistItem { + #[inline] + fn get_virtual_id(&self) -> u32 { + self.virtual_id + } + #[inline] + fn get_provider_id(&self) -> Option { + Some(self.provider_id) + } + #[inline] + fn get_category_id(&self) -> Option { + None + } + #[inline] + fn get_provider_url(&self) -> String { + self.url.to_string() + } + + #[inline] + fn get_uuid(&self) -> UUIDType { + generate_playlist_uuid(&self.input_name, &self.provider_id.to_string(), self.item_type, &self.url) + } + #[inline] + fn get_item_type(&self) -> PlaylistItemType { + self.item_type + } +} + +pub fn get_backdrop_path_value<'a>(field: &'a str, value: Option<&'a Value>) -> Option> { + match value { + Some(Value::String(url)) => Some(Cow::Borrowed(url)), + Some(Value::Array(values)) => { + match values.as_slice() { + [Value::String(single)] => Some(Cow::Borrowed(single)), + multiple if !multiple.is_empty() => { + if let Some(index) = field.rfind('_') { + if let Ok(bd_index) = field[index + 1..].parse::() { + if let Some(Value::String(selected)) = multiple.get(bd_index) { + return Some(Cow::Borrowed(selected)); + } + } + } + if let Value::String(url) = &multiple[0] { + Some(Cow::Borrowed(url)) + } else { + None + } + } + _ => None, + } + } + _ => None, + } +} + +macro_rules! generate_field_accessor_impl_for_xtream_playlist_item { + ($($prop:ident),*;) => { + impl crate::model::FieldGetAccessor for crate::model::XtreamPlaylistItem { + fn get_field(&self, field: &str) -> Option> { + let field = field.to_lowercase(); + match field.as_str() { + $( + stringify!($prop) => Some(Cow::Borrowed(&self.$prop)), + )* + "caption" => Some(if self.title.is_empty() { Cow::Borrowed(&self.name) } else { Cow::Borrowed(&self.title) }), + "epg_channel_id" | "epg_id" => self.epg_channel_id.as_ref().map(|s| Cow::Borrowed(s.as_str())), + _ => { + if field.starts_with(xtream_const::XC_PROP_BACKDROP_PATH) || field == xtream_const::XC_PROP_COVER { + let props = self.additional_properties.as_ref().and_then(|add_props| serde_json::from_str::>(add_props).ok()); + return match props { + Some(doc) => { + return if field == xtream_const::XC_PROP_COVER { + doc.get(&field).and_then(|value| value.as_str()).map(|s| Cow::::Owned(s.to_owned())) + } else { + get_backdrop_path_value(&field, doc.get(xtream_const::XC_PROP_BACKDROP_PATH)).map(|s| Cow::::Owned(s.to_string())) + } + } + _=> None, + } + } + None + }, + } + } + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlaylistItem { + pub header: PlaylistItemHeader, +} + +generate_field_accessor_impl_for_xtream_playlist_item!(name, logo, logo_small, group, title, parent_code, rec, url;); + +impl PlaylistItem { + pub fn to_m3u(&self) -> M3uPlaylistItem { + let header = &self.header; + M3uPlaylistItem { + virtual_id: header.virtual_id, + provider_id: header.id.to_string(), + name: if header.item_type == PlaylistItemType::Series { &header.title } else { &header.name }.to_string(), + chno: header.chno.to_string(), + logo: header.logo.to_string(), + logo_small: header.logo_small.to_string(), + group: header.group.to_string(), + title: header.title.to_string(), + parent_code: header.parent_code.to_string(), + audio_track: header.audio_track.to_string(), + time_shift: header.time_shift.to_string(), + rec: header.rec.to_string(), + url: header.url.to_string(), + epg_channel_id: header.epg_channel_id.clone(), + input_name: header.input_name.to_string(), + item_type: header.item_type, + t_stream_url: header.url.to_string(), + t_resource_url: None, + } + } + + pub fn to_xtream(&self) -> XtreamPlaylistItem { + let header = &self.header; + let provider_id = header.id.parse::().unwrap_or_default(); + let mut additional_properties = None; + if header.xtream_cluster != XtreamCluster::Live { + let add_ext = match header.get_additional_property("container_extension") { + None => true, + Some(ext) => ext.as_str().is_none_or(str::is_empty) + }; + if add_ext { + if let Some(cont_ext) = extract_extension_from_url(&header.url) { + let ext = if let Some(stripped) = cont_ext.strip_prefix('.') { stripped } else { cont_ext }; + let mut result = match header.additional_properties.as_ref() { + None => Map::new(), + Some(props) => { + if let Value::Object(map) = props { + map.clone() + } else { + Map::new() + } + } + }; + result.insert("container_extension".to_string(), Value::String(ext.to_string())); + additional_properties = serde_json::to_string(&Value::Object(result)).ok(); + } + } + } + if additional_properties.is_none() { + additional_properties = header.additional_properties.as_ref().and_then(|props| { + serde_json::to_string(props).ok() + }); + } + // let additional_properties = header.additional_properties.as_ref().and_then(|props| { + // serde_json::to_string(props).ok() + // }); + + XtreamPlaylistItem { + virtual_id: header.virtual_id, + provider_id, + name: if header.item_type == PlaylistItemType::Series { &header.title } else { &header.name }.to_string(), + logo: header.logo.to_string(), + logo_small: header.logo_small.to_string(), + group: header.group.to_string(), + title: header.title.to_string(), + parent_code: header.parent_code.to_string(), + rec: header.rec.to_string(), + url: header.url.to_string(), + epg_channel_id: header.epg_channel_id.clone(), + xtream_cluster: header.xtream_cluster, + additional_properties, + item_type: header.item_type, + category_id: header.category_id, + input_name: header.input_name.to_string(), + channel_no: header.chno.parse::().unwrap_or(0), + } + } +} + +impl PlaylistEntry for PlaylistItem { + #[inline] + fn get_virtual_id(&self) -> u32 { + self.header.virtual_id + } + + fn get_provider_id(&self) -> Option { + let header = &self.header; + get_provider_id(&header.id, &header.url) + } + + #[inline] + fn get_category_id(&self) -> Option { + None + } + + #[inline] + fn get_provider_url(&self) -> String { + self.header.url.to_string() + } + #[inline] + fn get_uuid(&self) -> UUIDType { + let header = &self.header; + generate_playlist_uuid(&header.input_name, &header.id, header.item_type, &header.url) + } + + #[inline] + fn get_item_type(&self) -> PlaylistItemType { + self.header.item_type + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlaylistGroup { + pub id: u32, + pub title: String, + pub channels: Vec, + #[serde(skip_serializing, skip_deserializing)] + pub xtream_cluster: XtreamCluster, +} + +impl PlaylistGroup { + #[inline] + pub fn on_load(&mut self) { + for pl in &mut self.channels { + pl.header.gen_uuid(); + } + } + + #[inline] + pub fn filter_count(&self, filter: F) -> usize + where + F: Fn(&PlaylistItem) -> bool, + { + self.channels.iter().filter(|&c| filter(c)).count() + } +} diff --git a/backend/src/model/xtream_const.rs b/shared/src/model/xtream_const.rs similarity index 91% rename from backend/src/model/xtream_const.rs rename to shared/src/model/xtream_const.rs index dd70013af..148478d78 100644 --- a/backend/src/model/xtream_const.rs +++ b/shared/src/model/xtream_const.rs @@ -45,20 +45,20 @@ pub const XC_FILE_SERIES_EPISODE_RECORD: &str = "series_episode_record"; pub const XC_TAG_SERIES_INFO_LAST_MODIFIED: &str = "last_modified"; -pub(in crate::model) const LIVE_STREAM_FIELDS: &[&str] = &[]; +pub const LIVE_STREAM_FIELDS: &[&str] = &[]; -pub(in crate::model) const VIDEO_STREAM_FIELDS: &[&str] = &[ +pub const VIDEO_STREAM_FIELDS: &[&str] = &[ "release_date", "cast", "director", "episode_run_time", "genre", "stream_type", "title", "year", "youtube_trailer", "trailer", "plot", "rating_5based", "stream_icon", "container_extension" ]; -pub(in crate::model) const SERIES_STREAM_FIELDS: &[&str] = &[ +pub const SERIES_STREAM_FIELDS: &[&str] = &[ XC_PROP_BACKDROP_PATH, "cast", XC_PROP_COVER, "director", "episode_run_time", "genre", "last_modified", "name", "plot", "rating_5based", "stream_type", "title", "year", "youtube_trailer", "trailer" ]; -pub(in crate::model) const XTREAM_VOD_REWRITE_URL_PROPS: &[&str] = &[XC_PROP_COVER]; +pub const XTREAM_VOD_REWRITE_URL_PROPS: &[&str] = &[XC_PROP_COVER]; diff --git a/backend/src/tools/directed_graph.rs b/shared/src/utils/directed_graph.rs similarity index 99% rename from backend/src/tools/directed_graph.rs rename to shared/src/utils/directed_graph.rs index 92312ee35..04b2369c3 100644 --- a/backend/src/tools/directed_graph.rs +++ b/shared/src/utils/directed_graph.rs @@ -207,8 +207,8 @@ where #[cfg(test)] mod tests { - use crate::tools::directed_graph::DirectedGraph; use std::collections::HashSet; + use crate::utils::DirectedGraph; fn are_vecs_equal(vec1: &Vec<&str>, vec2: &Vec<&str>) -> bool { let set1: HashSet = vec1.iter().map(|s| (*s).to_string()).collect(); diff --git a/backend/src/utils/hash_utils.rs b/shared/src/utils/hash_utils.rs similarity index 97% rename from backend/src/utils/hash_utils.rs rename to shared/src/utils/hash_utils.rs index f6ad81c21..0720557df 100644 --- a/backend/src/utils/hash_utils.rs +++ b/shared/src/utils/hash_utils.rs @@ -1,7 +1,7 @@ use base64::Engine; use base64::engine::general_purpose; -use shared::model::{PlaylistItemType, UUIDType}; use std::fmt::Write; +use crate::model::{PlaylistItemType, UUIDType}; #[inline] pub fn hash_bytes(bytes: &[u8]) -> UUIDType { diff --git a/shared/src/utils/json_utils.rs b/shared/src/utils/json_utils.rs new file mode 100644 index 000000000..dd641940f --- /dev/null +++ b/shared/src/utils/json_utils.rs @@ -0,0 +1,167 @@ +use std::io::{self, Read}; + +use serde::de::DeserializeOwned; +use serde::{Deserialize}; +use serde_json::{self, Deserializer, Value}; + +fn read_skipping_ws(mut reader: impl Read) -> io::Result { + loop { + let mut byte = 0u8; + reader.read_exact(std::slice::from_mut(&mut byte))?; + if !byte.is_ascii_whitespace() { + return Ok(byte); + } + } +} + +fn invalid_data(msg: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, msg) +} + +fn deserialize_single(reader: R) -> io::Result { + let next_obj = Deserializer::from_reader(reader).into_iter::().next(); + next_obj.map_or_else( + || Err(invalid_data("premature EOF")), + |result| result.map_err(Into::into), + ) +} + +fn yield_next_obj( + mut reader: R, + at_start: &mut bool, +) -> io::Result> { + if *at_start { + match read_skipping_ws(&mut reader)? { + b',' => deserialize_single(reader).map(Some), + b']' => Ok(None), + _ => Err(invalid_data("`,` or `]` not found")), + } + } else { + *at_start = true; + if read_skipping_ws(&mut reader)? == b'[' { + // read the next char to see if the array is empty + let peek = read_skipping_ws(&mut reader)?; + if peek == b']' { + Ok(None) + } else { + deserialize_single(io::Cursor::new([peek]).chain(reader)).map(Some) + } + } else { + Err(invalid_data("`[` not found")) + } + } +} + +// https://stackoverflow.com/questions/68641157/how-can-i-stream-elements-from-inside-a-json-array-using-serde-json +pub fn json_iter_array( + mut reader: R, +) -> impl Iterator> { + let mut at_start = false; + std::iter::from_fn(move || yield_next_obj(&mut reader, &mut at_start).transpose()) +} + +pub fn string_or_number_u32<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value: Value = serde::Deserialize::deserialize(deserializer)?; + + match value { + Value::Number(num) => { + if let Some(v) = num.as_u64() { + u32::try_from(v) + .map_err(|_| serde::de::Error::custom("Number out of range for u32")) + } else { + Err(serde::de::Error::custom("Invalid number")) + } + } + Value::String(s) => s + .parse::() + .map_err(|_| serde::de::Error::custom("Invalid string number")), + _ => Err(serde::de::Error::custom("Expected number or string")), + } +} + +pub fn opt_string_or_number_u32<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value: Value = serde::Deserialize::deserialize(deserializer)?; + + match value { + Value::Null => Ok(None), // Handle null explicitly + Value::Number(num) => { + if let Some(v) = num.as_u64() { + u32::try_from(v) + .map(Some) + .map_err(|_| serde::de::Error::custom("Number out of range for u32")) + } else { + Err(serde::de::Error::custom("Invalid number")) + } + } + Value::String(s) => s + .parse::() + .map(Some) + .map_err(|_| serde::de::Error::custom("Invalid string number")), + _ => Err(serde::de::Error::custom("Expected number, string, or null")), + } +} + +pub fn string_or_number_f64<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value: Value = serde::Deserialize::deserialize(deserializer)?; + + match value { + Value::Number(num) => num + .as_f64() + .ok_or_else(|| serde::de::Error::custom("Invalid number")), + Value::String(s) => s + .parse::() + .map_err(|_| serde::de::Error::custom("Invalid string number")), + _ => Err(serde::de::Error::custom("Expected number or string")), + } +} + +pub fn get_u64_from_serde_value(value: &Value) -> Option { + match value { + Value::Number(num_val) => num_val.as_u64(), + Value::String(str_val) => str_val.parse::().ok(), + _ => None, + } +} + +pub fn get_i64_from_serde_value(value: &Value) -> Option { + match value { + Value::Number(num_val) => num_val.as_i64(), + Value::String(str_val) => str_val.parse::().ok(), + _ => None, + } +} + +pub fn get_u32_from_serde_value(value: &Value) -> Option { + get_u64_from_serde_value(value).and_then(|val| u32::try_from(val).ok()) +} + +pub fn get_string_from_serde_value(value: &Value) -> Option { + match value { + Value::Number(num_val) => num_val.as_i64().map(|num| num.to_string()), + Value::String(str_val) => { + if str_val.is_empty() { + None + } else { + Some(str_val.clone()) + } + } + _ => None, + } +} + +pub fn string_default_on_null<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value: Option = Option::deserialize(deserializer)?; + Ok(value.unwrap_or_default()) +} \ No newline at end of file diff --git a/shared/src/utils/mod.rs b/shared/src/utils/mod.rs index c02c842ed..f1a70407e 100644 --- a/shared/src/utils/mod.rs +++ b/shared/src/utils/mod.rs @@ -3,9 +3,20 @@ mod time_utils; mod string_utils; mod size_utils; mod constants; +mod request; +mod directed_graph; +mod hash_utils; +mod json_utils; +mod serde_utils; + pub use self::default_utils::*; pub use self::time_utils::*; pub use self::string_utils::*; pub use self::size_utils::*; pub use self::constants::*; +pub use self::request::*; +pub use self::directed_graph::*; +pub use self::hash_utils::*; +pub use self::json_utils::*; +pub use self::serde_utils::*; diff --git a/shared/src/utils/request.rs b/shared/src/utils/request.rs new file mode 100644 index 000000000..6be7d5fd5 --- /dev/null +++ b/shared/src/utils/request.rs @@ -0,0 +1,121 @@ +use std::borrow::Cow; +use std::sync::atomic::Ordering; +use url::Url; +use crate::utils::{CONSTANTS, DASH_EXT, DASH_EXT_FRAGMENT, DASH_EXT_QUERY, HLS_EXT, HLS_EXT_FRAGMENT, HLS_EXT_QUERY}; + + +pub fn set_sanitize_sensitive_info(value: bool) { + CONSTANTS.sanitize.store(value, Ordering::SeqCst); +} +pub fn sanitize_sensitive_info(query: &str) -> Cow { + if !CONSTANTS.sanitize.load(Ordering::SeqCst) { + return Cow::Borrowed(query); + } + + let mut result = query.to_owned(); + + for (re, replacement) in &[ + (&CONSTANTS.re_credentials, "$1***"), + (&CONSTANTS.re_ipv4, "$1***"), + (&CONSTANTS.re_ipv6, "$1***"), + (&CONSTANTS.re_stream_url, "$1***/$2/***"), + (&CONSTANTS.re_url, "$1***/$2"), + ] { + result = re.replace_all(&result, *replacement).into_owned(); + } + Cow::Owned(result) +} + +#[inline] +fn ensure_extension(ext: &str) -> Option<&str> { + if ext.len() > 4 { + return None; + } + Some(ext) +} + +pub fn extract_extension_from_url(url: &str) -> Option<&str> { + if let Some(protocol_pos) = url.find("://") { + if let Some(last_slash_pos) = url[protocol_pos + 3..].rfind('/') { + let path = &url[protocol_pos + 3 + last_slash_pos + 1..]; + if let Some(last_dot_pos) = path.rfind('.') { + return ensure_extension(&path[last_dot_pos..]); + } + } + } else if let Some(last_dot_pos) = url.rfind('.') { + if last_dot_pos > url.rfind('/').unwrap_or(0) { + return ensure_extension(&url[last_dot_pos..]); + } + } + None +} + +pub fn is_hls_url(url: &str) -> bool { + let lc_url = url.to_lowercase(); + lc_url.ends_with(HLS_EXT) || lc_url.contains(HLS_EXT_QUERY) || lc_url.contains(HLS_EXT_FRAGMENT) +} + +pub fn is_dash_url(url: &str) -> bool { + let lc_url = url.to_lowercase(); + lc_url.ends_with(DASH_EXT) || lc_url.contains(DASH_EXT_QUERY) || lc_url.contains(DASH_EXT_FRAGMENT) +} + +pub fn replace_url_extension(url: &str, new_ext: &str) -> String { + let ext = new_ext.strip_prefix('.').unwrap_or(new_ext); // Remove leading dot if exists + + // Split URL into the base part (domain and path) and the suffix (query/fragment) + let (base_url, suffix) = match url.find(['?', '#'].as_ref()) { + Some(pos) => (&url[..pos], &url[pos..]), // Base URL and suffix + None => (url, ""), // No query or fragment + }; + + // Find the last '/' in the base URL, which marks the end of the domain and the beginning of the file path + if let Some(last_slash_pos) = base_url.rfind('/') { + if last_slash_pos < 9 { // protocol slash, return url as is + return url.to_string(); + } + let (path_part, file_name_with_extension) = base_url.split_at(last_slash_pos + 1); + // Find the last dot in the file name to replace the extension + if let Some(dot_pos) = file_name_with_extension.rfind('.') { + return format!( + "{}{}.{}{}", + path_part, + &file_name_with_extension[..dot_pos], // Keep the name part before the dot + ext, // Add the new extension + suffix // Add the query or fragment if any + ); + } + } + + // If no extension is found, add the new extension to the base URL + format!("{}{}.{}{}", base_url, "", ext, suffix) +} + +pub fn get_credentials_from_url(url: &Url) -> (Option, Option) { + let mut username = None; + let mut password = None; + for (key, value) in url.query_pairs() { + if key.eq("username") { + username = Some(value.to_string()); + } else if key.eq("password") { + password = Some(value.to_string()); + } + } + (username, password) +} + +pub fn get_credentials_from_url_str(url_with_credentials: &str) -> (Option, Option) { + if let Ok(url) = Url::parse(url_with_credentials) { + get_credentials_from_url(&url) + } else { + (None, None) + } +} + +pub fn get_base_url_from_str(url: &str) -> Option { + if let Ok(url) = Url::parse(url) { + Some(url.origin().ascii_serialization()) + } else { + None + } +} diff --git a/backend/src/utils/serde_utils.rs b/shared/src/utils/serde_utils.rs similarity index 100% rename from backend/src/utils/serde_utils.rs rename to shared/src/utils/serde_utils.rs diff --git a/shared/src/utils/string_utils.rs b/shared/src/utils/string_utils.rs index c866c0e6b..0adc27586 100644 --- a/shared/src/utils/string_utils.rs +++ b/shared/src/utils/string_utils.rs @@ -55,6 +55,7 @@ pub fn get_non_empty_str<'a>(first: &'a str, second: &'a str, third: &'a str) -> #[cfg(test)] mod test { use std::collections::HashSet; + use crate::utils::Capitalize; use super::generate_random_string; #[test]