diff --git a/CHANGELOG.md b/CHANGELOG.md index cd98b7a97..00b79c279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -146,6 +146,8 @@ active URL of the specified provider. - Added `resolve_tmdb`: Triggers TMDB lookup if ID is missing. - Added `probe_stream`: Triggers ffprobe if technical info is missing. - Added `probe_delay`: Delay between probe tasks (default `50` seconds). + - Added `disable_hls_streaming`: Disables HLS reverse-proxy mode for live streams and requests direct TS stream endpoints instead. + - Added `staged.enabled`: Disables/enables the staged input - **source.yml (target output)**: - Added `probe_live`: Enables background probing for Live TV streams (default disabled). - Added `probe_live_interval_hours`: Sets the frequency for re-probing Live TV streams. @@ -192,12 +194,17 @@ active URL of the specified provider. - **Messaging Config View**: New UI for configuring Discord and enhanced REST settings. - **Performance Monitoring**: Added CPU usage display to the dashboard. - **Stream Table Enhancements**: Added "Copy-To-Clipboard" functions and improved connection monitoring. +- **Streams Table Episode Title**: Stream rows now prefer explicit episode titles instead of falling back to the series name. - **UX Improvements**: Implemented API-user category selection and better session tracking for HLS. - **Filter View**: Compacted pretty printing for filters. - **Mapper View**: Updated to support new `for_each` syntax. - Added **Stream Buffer** settings (Enabled, Size) to Reverse Proxy configuration UI. - Added **TMDB** settings (Rate Limit, Cache Duration, Language) and **Metadata Formats** (NFO support) to Library configuration UI. - Introduced the **Metadata Update** config tab, with FFprobe controls relocated from **Video** into it. +- **Playlist Explorer Resources**: Channel logos (and non-local episode images) are loaded via authenticated same-origin resource endpoints so HTTP + upstream assets still render behind HTTPS frontends. +- **Local Library Episode Backgrounds**: Local series episode `movie_image` values are now kept as direct TMDB image URLs in series info documents and + rendered directly in Playlist Explorer. ## 🚀 Performance & Stability @@ -223,14 +230,18 @@ active URL of the specified provider. - **Shared Stream Shutdown**: Drops registry locks before releasing provider handles. - **EPG Icon URLs**: Rewritten in reverse proxy mode. - **Short EPG**: Served from local disk. +- **EPG Memory Cache**: Added target-scoped in-memory EPG cache (when `use_memory_cache=true`) to reduce disk access for WebUI and short EPG + lookups. - **Client Requests**: Extended debug logging for client requests and ID chain. - **XTream Fixes**: Fixed series/catch-up lookups using `series-info virtual_id`. +- **Catchup Stability**: Fixed catchup parent mapping/session isolation to keep playback/account selection stable on the same user account. - **Cloudflare Header**: Added `cloudflare_header` to reverse proxy `disable_header` settings. - **Kick Seconds**: `kick_secs` added to `config.yml web_ui` config. - **Improved connection handling** for users with strict connection limits during streaming operations. - **Fixed streaming response handling** for specific content types. - **Enhanced validation of response headers** to prevent invalid values. - **Corrected request header prioritization logic**. +- **HLS-to-TS Fallback**: Added optional non-HLS fallback path for live streams by forcing direct TS stream endpoints. - **Fix**: Re-instated EPG Title Synchronization after playlist updates. - **Optimization**: Significant EPG memory reduction. - **Optimization**: Improved EPG parsing performance. diff --git a/README.md b/README.md index 4ff9fd136..c97071c5b 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Define complex filters using expressive logic, e.g.: - Show a fallback video stream if a channel becomes unavailable. - Integrate **HDHomeRun** devices with **Plex**, **Emby**, or **Jellyfin**. - Use provider aliases to manage multiple lines from the same source. +- Optional per-input `disable_hls_streaming` flag to force direct TS stream endpoints instead of HLS reverse-proxy streaming. ## 🐋 Docker Container Templates @@ -1164,6 +1165,7 @@ The local media file library module enables Tuliprox to scan, classify, and serv - NFO file reading and writing (Kodi/Jellyfin/Emby/Plex compatible) - Incremental scanning (only processes changed files) - Virtual ID management for stable playlist integration +- Local series episode backgrounds in Playlist Explorer use direct TMDB still-image URLs **Configuration Example**: @@ -1331,6 +1333,7 @@ Each input has the following attributes: - `xtream_skip_series` true or false, series section can be skipped. - `xtream_live_stream_without_extension` default false, if set to true `.ts` extension is not added to the stream link. - `xtream_live_stream_use_prefix` default true, if set to true `/live/` prefix is added to the stream link. + - `disable_hls_streaming` default false, if set to true HLS reverse-proxy playback is disabled for live streams and TS stream endpoints are used. - `resolve_tmdb`: `true`|`false` Attempts to resolve missing TMDB IDs via TMDB API based on title. - `probe_stream`: `true`|`false` Probes stream if video/audio info is missing in provider data. Probing respects the `max_connections` limit of your provider input. If no connection slot is available, the item is skipped and retried during the next update. @@ -1360,6 +1363,7 @@ the list. All streaming, proxying, and metadata still come from the provider’s `staged` has the following properties: +- `enabled`, default false - `type` is optional, default is `m3u`. Valid values are `m3u` and `xtream` - `url` for type `m3u` is the download url or a local filename (can be gzip) of the input-source. For type `xtream`it is `http://:` - `headers` is optional diff --git a/backend/src/library/metadata_storage.rs b/backend/src/library/metadata_storage.rs index e7e3db79a..003b805db 100644 --- a/backend/src/library/metadata_storage.rs +++ b/backend/src/library/metadata_storage.rs @@ -32,7 +32,7 @@ impl MetadataStorage { // Stores metadata for a video file pub async fn store(&self, entry: &MetadataCacheEntry) -> std::io::Result<()> { - let file_path = self.get_metadata_file_path(&entry.uuid); + let file_path = self.get_library_metadata_file_path(&entry.uuid); debug!("Storing metadata for {}: {}", entry.file_path, file_path.clean().display()); @@ -53,7 +53,7 @@ impl MetadataStorage { // Loads metadata for a specific UUID pub async fn load_by_uuid(&self, uuid: &str) -> Option { - let file_path = self.get_metadata_file_path(uuid); + let file_path = self.get_library_metadata_file_path(uuid); if !fs::try_exists(&file_path).await.unwrap_or(false) { return None; @@ -109,7 +109,7 @@ impl MetadataStorage { // Deletes metadata for a specific UUID pub async fn delete_by_uuid(&self, uuid: &str) -> std::io::Result<()> { - let file_path = self.get_metadata_file_path(uuid); + let file_path = self.get_library_metadata_file_path(uuid); if fs::try_exists(&file_path).await.unwrap_or(false) { debug!("Deleting metadata file: {}", file_path.display()); @@ -151,8 +151,8 @@ impl MetadataStorage { .collect() } - // Gets the metadata file path for a UUID - fn get_metadata_file_path(&self, uuid: &str) -> PathBuf { + // Gets the metadata file path for a Local library + fn get_library_metadata_file_path(&self, uuid: &str) -> PathBuf { self.storage_dir.join("library").join(format!("{uuid}.json")) } diff --git a/backend/src/library/tmdb.rs b/backend/src/library/tmdb.rs index 78ca93c55..b5b74ad82 100644 --- a/backend/src/library/tmdb.rs +++ b/backend/src/library/tmdb.rs @@ -9,6 +9,23 @@ fn some_if_nonempty(v: Vec) -> Option> { if v.is_empty() { None } else { Some(v) } } +fn resolve_tmdb_image_url(path: &str) -> Option { + let normalized = path.trim(); + if normalized.is_empty() { + return None; + } + if normalized.starts_with("http://") || normalized.starts_with("https://") { + return Some(normalized.to_string()); + } + if normalized.starts_with("//") { + return Some(format!("https:{normalized}")); + } + if normalized.starts_with('/') { + return Some(format!("{TMDB_IMAGE_BASE_URL}{normalized}")); + } + Some(format!("{TMDB_IMAGE_BASE_URL}/{normalized}")) +} + // helper function: Crew-Filter fn crew_names(credits: Option<&TmdbCredits>, jobs: &[&str]) -> Option> { credits @@ -154,14 +171,14 @@ impl TmdbMovieDetails { role: Some(actor.character.clone()), thumb: actor.profile_path .as_ref() - .map(|p| format!("{TMDB_IMAGE_BASE_URL}{p}")), + .and_then(|p| resolve_tmdb_image_url(p)), }) .collect::>() }) .and_then(some_if_nonempty), studios: self.production_companies.as_ref().map(|list| list.iter().map(|n| n.name.clone()).collect()).and_then(some_if_nonempty), - poster: self.poster_path.as_ref().map(|p| format!("{TMDB_IMAGE_BASE_URL}{p}")), - fanart: self.backdrop_path.clone().map(|p| format!("{TMDB_IMAGE_BASE_URL}{p}")), + poster: self.poster_path.as_ref().and_then(|p| resolve_tmdb_image_url(p)), + fanart: self.backdrop_path.as_ref().and_then(|p| resolve_tmdb_image_url(p)), source: MetadataSource::Tmdb, last_updated: chrono::Utc::now().timestamp(), videos: self.videos @@ -213,7 +230,7 @@ impl TmdbSeriesInfoEpisodeDetails { plot: if self.overview.is_empty() {None} else { Some(self.overview.clone())}, runtime: self.runtime, rating: Some(self.vote_average), - thumb: self.still_path.clone(), + thumb: self.still_path.as_ref().and_then(|path| resolve_tmdb_image_url(path)), file_path: String::new(), file_size: 0, file_modified: 0, @@ -312,7 +329,7 @@ impl TmdbSeriesInfoDetails { role: Some(actor.character.clone()), thumb: actor.profile_path .as_ref() - .map(|p| format!("{TMDB_IMAGE_BASE_URL}{p}")), + .and_then(|p| resolve_tmdb_image_url(p)), }) .collect::>() }) @@ -325,12 +342,8 @@ impl TmdbSeriesInfoDetails { Some(result) } }), - poster: self - .poster_path.clone() - .map(|p| format!("{TMDB_IMAGE_BASE_URL}{p}")), - fanart: self - .backdrop_path.clone() - .map(|p| format!("{TMDB_IMAGE_BASE_URL}{p}")), + poster: self.poster_path.as_ref().and_then(|p| resolve_tmdb_image_url(p)), + fanart: self.backdrop_path.as_ref().and_then(|p| resolve_tmdb_image_url(p)), status: Some(self.status.clone()), seasons: self.seasons.as_ref().map(|seasons| seasons.iter().map(TmdbSeason::to_meta_data).collect()), episodes: self.seasons @@ -459,3 +472,43 @@ impl TmdbSeason { } } +#[cfg(test)] +mod tests { + use super::{resolve_tmdb_image_url, TMDB_IMAGE_BASE_URL}; + + #[test] + fn test_resolve_tmdb_url_with_absolute_http() { + let input = "http://cdn.example.org/image.jpg"; + assert_eq!(resolve_tmdb_image_url(input), Some(input.to_string())); + } + + #[test] + fn test_resolve_tmdb_url_with_absolute_https() { + let input = "https://cdn.example.org/image.jpg"; + assert_eq!(resolve_tmdb_image_url(input), Some(input.to_string())); + } + + #[test] + fn test_resolve_tmdb_url_with_protocol_relative() { + let input = "//cdn.example.org/image.jpg"; + assert_eq!(resolve_tmdb_image_url(input), Some(String::from("https://cdn.example.org/image.jpg"))); + } + + #[test] + fn test_resolve_tmdb_url_with_leading_slash() { + let input = "/abc/def.jpg"; + assert_eq!(resolve_tmdb_image_url(input), Some(format!("{TMDB_IMAGE_BASE_URL}{input}"))); + } + + #[test] + fn test_resolve_tmdb_url_with_relative_path() { + let input = "abc/def.jpg"; + assert_eq!(resolve_tmdb_image_url(input), Some(format!("{TMDB_IMAGE_BASE_URL}/{input}"))); + } + + #[test] + fn test_resolve_tmdb_url_with_blank_path() { + let input = " "; + assert_eq!(resolve_tmdb_image_url(input), None); + } +} diff --git a/backend/src/model/config/input.rs b/backend/src/model/config/input.rs index a6ed18f97..4ec25072a 100644 --- a/backend/src/model/config/input.rs +++ b/backend/src/model/config/input.rs @@ -157,6 +157,7 @@ impl InputUserInfo { #[derive(Debug, Clone, Default)] pub struct StagedInput { + pub enabled: bool, pub name: Arc, pub url: String, pub username: Option, @@ -172,6 +173,7 @@ macros::from_impl!(StagedInput); impl From<&StagedInputDto> for StagedInput { fn from(dto: &StagedInputDto) -> Self { Self { + enabled: dto.enabled, name: dto.name.clone(), input_type: dto.input_type, url: dto.url.clone(), diff --git a/backend/src/model/config/web_ui.rs b/backend/src/model/config/web_ui.rs index d64c349ff..4fa0671f9 100644 --- a/backend/src/model/config/web_ui.rs +++ b/backend/src/model/config/web_ui.rs @@ -64,7 +64,15 @@ impl From<&WebUiConfigDto> for WebUiConfig { enabled: dto.enabled, user_ui_enabled: dto.user_ui_enabled, content_security_policy: dto.content_security_policy.as_ref().map(Into::into), - path: dto.path.clone(), + path: dto.path.as_ref().and_then(|path| { + let trimmed = path.trim(); + let normalized = trimmed.trim_matches('/'); + if normalized.is_empty() { + None + } else { + Some(normalized.to_string()) + } + }), auth: dto.auth.as_ref().map(Into::into), player_server: dto.player_server.clone(), kick_secs: dto.kick_secs, @@ -86,4 +94,3 @@ impl From<&WebUiConfig> for WebUiConfigDto { } } } - diff --git a/backend/src/processing/processor/library.rs b/backend/src/processing/processor/library.rs index e5fbbcdb1..ac665b2c5 100644 --- a/backend/src/processing/processor/library.rs +++ b/backend/src/processing/processor/library.rs @@ -15,7 +15,8 @@ pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &A if !library_config.enabled { return (vec![], vec![]); } let storage_path = config.metadata_update.as_ref() - .map_or_else(|| std::path::PathBuf::from(shared::utils::default_metadata_path()), |m| std::path::PathBuf::from(&m.cache_path)); + .map_or_else(|| std::path::PathBuf::from(shared::utils::default_metadata_path()), |m| std::path::PathBuf::from(&m.cache_path)) + .join("library"); let mut metadata_iter = MetadataAsyncIter::new(&storage_path).await; let mut group_movies = PlaylistGroup { id: 0, diff --git a/backend/src/processing/processor/xtream_vod.rs b/backend/src/processing/processor/xtream_vod.rs index 4d9c0e730..ef0cc0052 100644 --- a/backend/src/processing/processor/xtream_vod.rs +++ b/backend/src/processing/processor/xtream_vod.rs @@ -728,10 +728,15 @@ pub async fn update_vod_metadata( properties.tmdb = m.tmdb_id(); properties_updated = true; } + if properties.details.is_none() { + properties.details = Some(VideoStreamDetailProperties::default()); + } if let Some(details) = properties.details.as_mut() { if details.release_date.is_none() { - details.release_date = m.year().map(|y| format!("{y}-01-01").into()); - properties_updated = true; + if let Some(year) = m.year() { + details.release_date = Some(format!("{year}-01-01").into()); + properties_updated = true; + } } } if properties_updated { diff --git a/backend/src/utils/network/m3u.rs b/backend/src/utils/network/m3u.rs index 253ce24b2..cb359f97f 100644 --- a/backend/src/utils/network/m3u.rs +++ b/backend/src/utils/network/m3u.rs @@ -16,7 +16,7 @@ pub async fn download_m3u_playlist( let input_source: InputSource = { match input.staged.as_ref() { None => input.into(), - Some(staged) => staged.into(), + Some(staged) => if staged.enabled { staged.into() } else { input.into() }, } }; let persist_file_path = prepare_file_path(input.persist.as_deref(), working_dir, ""); diff --git a/backend/src/utils/network/xtream.rs b/backend/src/utils/network/xtream.rs index 56a776c96..f43f5c083 100644 --- a/backend/src/utils/network/xtream.rs +++ b/backend/src/utils/network/xtream.rs @@ -350,7 +350,7 @@ pub async fn download_xtream_playlist(app_config: &Arc, client: &reqw let input_source: InputSource = { match input.staged.as_ref() { None => input.into(), - Some(staged) => staged.into(), + Some(staged) => if staged.enabled { staged.into() } else { input.into() }, } }; diff --git a/frontend/src/app/components/config/config_update.rs b/frontend/src/app/components/config/config_update.rs index 9fb739de0..8bfcb77ca 100644 --- a/frontend/src/app/components/config/config_update.rs +++ b/frontend/src/app/components/config/config_update.rs @@ -61,8 +61,10 @@ fn update_library_field(config: &mut ConfigDto, mut library_cfg: LibraryConfigDt } fn is_webui_toggle_only_update(cfg: &WebUiConfigDto) -> bool { - cfg.path.as_deref().is_none_or(|path| path.trim().is_empty()) - && cfg.player_server.as_deref().is_none_or(|player_server| player_server.trim().is_empty()) + cfg.path.as_deref().is_none_or(|path| { + let trimmed = path.trim(); + trimmed.is_empty() || trimmed.chars().all(|c| c == '/') + }) && cfg.player_server.as_deref().is_none_or(|player_server| player_server.trim().is_empty()) && cfg.kick_secs == WebUiConfigDto::default().kick_secs && cfg.auth.as_ref().is_none_or(WebAuthConfigDto::is_empty) && cfg.content_security_policy.as_ref().is_none_or(ContentSecurityPolicyConfigDto::is_empty) @@ -245,12 +247,12 @@ mod tests { assert!(!metadata_cfg.is_empty()); metadata_cfg.clean(); - assert_eq!(metadata_cfg.ffprobe.timeout, None); + assert_eq!(metadata_cfg.ffprobe.timeout, Some(60)); update_config(&mut config, vec![ConfigForm::MetadataUpdate(true, metadata_cfg)]); let stored = config.metadata_update.as_ref().expect("metadata_update config should be set"); assert!(stored.ffprobe.enabled); - assert_eq!(stored.ffprobe.timeout, None); + assert_eq!(stored.ffprobe.timeout, Some(60)); } } diff --git a/frontend/src/app/components/playlist/playlist_explorer.rs b/frontend/src/app/components/playlist/playlist_explorer.rs index 97178c4f4..84edd3ff6 100644 --- a/frontend/src/app/components/playlist/playlist_explorer.rs +++ b/frontend/src/app/components/playlist/playlist_explorer.rs @@ -486,7 +486,7 @@ pub fn PlaylistExplorer() -> Html { } }; - let render_episode = |chan: &&SeriesStreamDetailEpisodeProperties| { + let render_episode = |chan: &SeriesStreamDetailEpisodeProperties| { let channel_select = ChannelSelection { virtual_id: chan.id, cluster: XtreamCluster::Series, @@ -495,7 +495,7 @@ pub fn PlaylistExplorer() -> Html { let popup_onclick = handle_episode_popup_onclick.clone(); let rating = chan.rating.unwrap_or_default(); html! { - + {render_channel_logo(&chan.movie_image)} { html_if!(rating > 0.001, { diff --git a/frontend/src/app/components/setup/setup_helpers.rs b/frontend/src/app/components/setup/setup_helpers.rs index fa177f3ea..8b39230e4 100644 --- a/frontend/src/app/components/setup/setup_helpers.rs +++ b/frontend/src/app/components/setup/setup_helpers.rs @@ -232,8 +232,10 @@ fn apply_setup_library_form(config: &mut shared::model::ConfigDto, library_cfg: } fn is_setup_webui_toggle_only_update(cfg: &WebUiConfigDto) -> bool { - cfg.path.as_deref().is_none_or(|path| path.trim().is_empty()) - && cfg.player_server.as_deref().is_none_or(|player_server| player_server.trim().is_empty()) + cfg.path.as_deref().is_none_or(|path| { + let trimmed = path.trim(); + trimmed.is_empty() || trimmed.chars().all(|c| c == '/') + }) && cfg.player_server.as_deref().is_none_or(|player_server| player_server.trim().is_empty()) && cfg.kick_secs == WebUiConfigDto::default().kick_secs && cfg.auth.as_ref().is_none_or(WebAuthConfigDto::is_empty) && cfg.content_security_policy.as_ref().is_none_or(ContentSecurityPolicyConfigDto::is_empty) @@ -682,6 +684,6 @@ mod tests { let app_cfg = build_setup_app_config(&config_ctx, &form_state, SourcesConfigDto::default()); let metadata_update = app_cfg.config.metadata_update.expect("metadata_update config should be present"); assert!(metadata_update.ffprobe.enabled); - assert_eq!(metadata_update.ffprobe.timeout, None); + assert_eq!(metadata_update.ffprobe.timeout, Some(60)); } } diff --git a/frontend/src/app/components/source_editor/input_form.rs b/frontend/src/app/components/source_editor/input_form.rs index dc6744fdd..2c7287c00 100644 --- a/frontend/src/app/components/source_editor/input_form.rs +++ b/frontend/src/app/components/source_editor/input_form.rs @@ -143,6 +143,7 @@ generate_form_reducer!( state: StagedInputDtoFormState { form: StagedInputDto }, action_name: StagedInputFormAction, fields { + Enabled => enabled: bool, Url => url: String, Username => username: Option, Password => password: Option, @@ -566,6 +567,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let staged_input_state_2 = staged_input_state.clone(); html! { + { edit_field_bool!(staged_input_state, translate.t(LABEL_ENABLED), enabled, StagedInputFormAction::Enabled) } { edit_field_text!(staged_input_state, translate.t(LABEL_URL), url, StagedInputFormAction::Url) }
{ edit_field_text_option!(staged_input_state, translate.t(LABEL_USERNAME), username, StagedInputFormAction::Username) } @@ -976,9 +978,9 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { {html_if!(!library_input, { })} - { html_if!(!library_input, { <> + diff --git a/shared/src/model/config/base.rs b/shared/src/model/config/base.rs index 5e8e4eb6a..bdd1de2c6 100644 --- a/shared/src/model/config/base.rs +++ b/shared/src/model/config/base.rs @@ -7,13 +7,47 @@ use crate::{ }, utils::{ default_connect_timeout_secs, default_supported_video_extensions, is_blank_optional_string, - is_default_connect_timeout_secs, is_false, + is_default_connect_timeout_secs, is_false, CONFIG_PATH, MAPPING_FILE, TEMPLATE_FILE, }, }; pub const DEFAULT_USER_AGENT: &str = "VLC/3.0.16 LibVLC/3.0.16"; fn default_default_user_agent() -> Option { Some(DEFAULT_USER_AGENT.to_string()) } +fn default_main_mapping_path() -> Option { Some(format!("./{CONFIG_PATH}/{MAPPING_FILE}")) } +fn default_main_template_path() -> Option { Some(format!("./{CONFIG_PATH}/{TEMPLATE_FILE}")) } + +fn is_default_config_file_path(value: &str, file_name: &str) -> bool { + let normalized = value.trim().replace('\\', "/"); + let normalized = normalized.trim_start_matches("./"); + normalized == file_name + || normalized.rsplit_once('/').is_some_and(|(dir, file)| dir == CONFIG_PATH && file == file_name) +} + +fn is_blank_or_default_mapping_path(path: &Option) -> bool { + path.as_ref().is_none_or(|value| value.trim().is_empty() || is_default_config_file_path(value, MAPPING_FILE)) +} + +fn is_blank_or_default_template_path(path: &Option) -> bool { + path.as_ref().is_none_or(|value| value.trim().is_empty() || is_default_config_file_path(value, TEMPLATE_FILE)) +} + +fn effective_main_mapping_path(path: Option) -> Option { + if is_blank_or_default_mapping_path(&path) { + default_main_mapping_path() + } else { + path + } +} + +fn effective_main_template_path(path: Option) -> Option { + if is_blank_or_default_template_path(&path) { + default_main_template_path() + } else { + path + } +} + fn is_none_or_empty_video(video: &Option) -> bool { video.as_ref().is_none_or(VideoConfigDto::is_empty) } @@ -35,9 +69,9 @@ pub struct ConfigDto { pub backup_dir: Option, #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub user_config_dir: Option, - #[serde(default, skip_serializing_if = "is_blank_optional_string")] + #[serde(default, skip_serializing_if = "is_blank_or_default_mapping_path")] pub mapping_path: Option, - #[serde(default, skip_serializing_if = "is_blank_optional_string")] + #[serde(default, skip_serializing_if = "is_blank_or_default_template_path")] pub template_path: Option, #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub custom_stream_response_path: Option, @@ -126,9 +160,9 @@ pub struct MainConfigDto { pub backup_dir: Option, #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub user_config_dir: Option, - #[serde(default, skip_serializing_if = "is_blank_optional_string")] + #[serde(default = "default_main_mapping_path", skip_serializing_if = "is_blank_or_default_mapping_path")] pub mapping_path: Option, - #[serde(default, skip_serializing_if = "is_blank_optional_string")] + #[serde(default = "default_main_template_path", skip_serializing_if = "is_blank_or_default_template_path")] pub template_path: Option, #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub custom_stream_response_path: Option, @@ -157,8 +191,8 @@ impl Default for MainConfigDto { default_user_agent: default_default_user_agent(), backup_dir: None, user_config_dir: None, - mapping_path: None, - template_path: None, + mapping_path: default_main_mapping_path(), + template_path: default_main_template_path(), custom_stream_response_path: None, user_access_control: false, connect_timeout_secs: default_connect_timeout_secs(), @@ -179,8 +213,8 @@ impl From<&ConfigDto> for MainConfigDto { default_user_agent: config.default_user_agent.clone(), backup_dir: config.backup_dir.clone(), user_config_dir: config.user_config_dir.clone(), - mapping_path: config.mapping_path.clone(), - template_path: config.template_path.clone(), + mapping_path: effective_main_mapping_path(config.mapping_path.clone()), + template_path: effective_main_template_path(config.template_path.clone()), custom_stream_response_path: config.custom_stream_response_path.clone(), user_access_control: config.user_access_control, connect_timeout_secs: config.connect_timeout_secs, diff --git a/shared/src/model/config/input.rs b/shared/src/model/config/input.rs index 5ea9a00ff..bb6dbac33 100644 --- a/shared/src/model/config/input.rs +++ b/shared/src/model/config/input.rs @@ -285,9 +285,13 @@ impl ConfigInputOptionsDto { } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] +fn default_true() -> bool { true } + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct StagedInputDto { + #[serde(default = "default_true")] + pub enabled: bool, #[serde(with = "arc_str_serde")] pub name: Arc, pub url: String, @@ -303,6 +307,21 @@ pub struct StagedInputDto { pub headers: HashMap, } +impl Default for StagedInputDto { + fn default() -> Self { + Self { + enabled: true, + name: Arc::default(), + url: String::default(), + username: Option::default(), + password: Option::default(), + method: InputFetchMethod::default(), + input_type: InputType::default(), + headers: HashMap::default(), + } + } +} + impl StagedInputDto { pub fn is_empty(&self) -> bool { self.url.trim().is_empty() diff --git a/shared/src/model/config/metadata_update.rs b/shared/src/model/config/metadata_update.rs index 8ab4edb1f..2a14798d6 100644 --- a/shared/src/model/config/metadata_update.rs +++ b/shared/src/model/config/metadata_update.rs @@ -35,6 +35,11 @@ const MAX_JITTER_PERCENT: u8 = 95; const MIN_QUEUE_SIZE: usize = 1; const DEFAULT_FFPROBE_TIMEOUT_SECS: u64 = 60; +fn default_ffprobe_timeout_secs() -> Option { Some(DEFAULT_FFPROBE_TIMEOUT_SECS) } +fn is_default_ffprobe_timeout(timeout: &Option) -> bool { + timeout.is_none_or(|value| value == DEFAULT_FFPROBE_TIMEOUT_SECS) +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct MetadataUpdateConfigDto { @@ -305,7 +310,7 @@ impl ProbeConfigDto { pub struct FfprobeConfigDto { #[serde(default, skip_serializing_if = "is_false")] pub enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(default = "default_ffprobe_timeout_secs", skip_serializing_if = "is_default_ffprobe_timeout")] pub timeout: Option, #[serde( default = "default_metadata_ffprobe_analyze_duration", @@ -337,7 +342,7 @@ impl Default for FfprobeConfigDto { fn default() -> Self { Self { enabled: false, - timeout: None, + timeout: default_ffprobe_timeout_secs(), analyze_duration: default_metadata_ffprobe_analyze_duration(), probe_size: default_metadata_ffprobe_probe_size(), live_analyze_duration: default_metadata_ffprobe_live_analyze_duration(), @@ -349,16 +354,28 @@ impl Default for FfprobeConfigDto { impl FfprobeConfigDto { pub fn is_empty(&self) -> bool { !self.enabled - && self.timeout.is_none() + && is_default_ffprobe_timeout(&self.timeout) && self.analyze_duration == default_metadata_ffprobe_analyze_duration() && self.probe_size == default_metadata_ffprobe_probe_size() && self.live_analyze_duration == default_metadata_ffprobe_live_analyze_duration() && self.live_probe_size == default_metadata_ffprobe_live_probe_size() } - fn clean(&mut self) { - if self.timeout.is_some_and(|v| v == DEFAULT_FFPROBE_TIMEOUT_SECS) { - self.timeout = None; + pub fn clean(&mut self) { + if self.timeout.is_none_or(|timeout| timeout == 0) { + self.timeout = default_ffprobe_timeout_secs(); + } + if self.analyze_duration.trim().is_empty() { + self.analyze_duration = default_metadata_ffprobe_analyze_duration(); + } + if self.probe_size.trim().is_empty() { + self.probe_size = default_metadata_ffprobe_probe_size(); + } + if self.live_analyze_duration.trim().is_empty() { + self.live_analyze_duration = default_metadata_ffprobe_live_analyze_duration(); + } + if self.live_probe_size.trim().is_empty() { + self.live_probe_size = default_metadata_ffprobe_live_probe_size(); } } diff --git a/shared/src/model/config/web_ui.rs b/shared/src/model/config/web_ui.rs index 062fe5ace..cb0541636 100644 --- a/shared/src/model/config/web_ui.rs +++ b/shared/src/model/config/web_ui.rs @@ -27,6 +27,15 @@ const RESERVED_PATHS: &[&str] = &[ "resource", ]; +fn default_web_ui_path() -> Option { Some("/".to_string()) } + +fn is_blank_or_default_web_ui_path(path: &Option) -> bool { + path.as_ref().is_none_or(|value| { + let trimmed = value.trim(); + trimmed.is_empty() || trimmed.chars().all(|c| c == '/') + }) +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields, rename_all = "kebab-case")] pub struct ContentSecurityPolicyConfigDto { @@ -77,7 +86,7 @@ pub struct WebUiConfigDto { pub user_ui_enabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub content_security_policy: Option, - #[serde(default, skip_serializing_if = "is_blank_optional_string")] + #[serde(default = "default_web_ui_path", skip_serializing_if = "is_blank_or_default_web_ui_path")] pub path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub auth: Option, @@ -95,7 +104,7 @@ impl Default for WebUiConfigDto { enabled: default_as_true(), user_ui_enabled: default_as_true(), content_security_policy: None, - path: None, + path: default_web_ui_path(), auth: None, player_server: None, kick_secs: default_kick_secs(), @@ -110,7 +119,7 @@ impl WebUiConfigDto { self.enabled == empty.enabled && self.user_ui_enabled == empty.user_ui_enabled && !self.combine_views_stats_streams - && is_blank_optional_str(self.path.as_deref()) + && is_blank_or_default_web_ui_path(&self.path) && is_blank_optional_str(self.player_server.as_deref()) && self.kick_secs == default_kick_secs() && (self.content_security_policy.is_none() @@ -126,7 +135,7 @@ impl WebUiConfigDto { self.auth = None; } - if is_blank_optional_str(self.path.as_deref()) { + if is_blank_or_default_web_ui_path(&self.path) { self.path = None; } if is_blank_optional_str(self.player_server.as_deref()) { @@ -145,14 +154,19 @@ impl WebUiConfigDto { if web_path.is_empty() { self.path = None; } else { - let web_path = web_path.trim().trim_start_matches('/').trim_end_matches('/').to_string(); - if RESERVED_PATHS.contains(&web_path.to_lowercase().as_str()) { - return Err(TuliproxError::new( - TuliproxErrorKind::Info, - format!("web ui path is a reserved path. Do not use {RESERVED_PATHS:?}"), - )); + let normalized_path = web_path.trim_start_matches('/').trim_end_matches('/'); + if normalized_path.is_empty() { + self.path = default_web_ui_path(); + } else { + let normalized_path = normalized_path.to_string(); + if RESERVED_PATHS.contains(&normalized_path.to_lowercase().as_str()) { + return Err(TuliproxError::new( + TuliproxErrorKind::Info, + format!("web ui path is a reserved path. Do not use {RESERVED_PATHS:?}"), + )); + } + self.path = Some(normalized_path); } - self.path = Some(web_path); } } if let Some(csp) = &self.content_security_policy { diff --git a/shared/src/model/stream_info.rs b/shared/src/model/stream_info.rs index 888034316..98aaa745d 100644 --- a/shared/src/model/stream_info.rs +++ b/shared/src/model/stream_info.rs @@ -1,6 +1,6 @@ use crate::{ model::{M3uPlaylistItem, PlaylistEntry, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}, - utils::{arc_str_serde, current_time_secs, is_blank_optional_string, longest}, + utils::{arc_str_serde, current_time_secs, is_blank_optional_string}, }; use serde::{Deserialize, Serialize}; use std::{net::SocketAddr, sync::Arc}; @@ -33,6 +33,7 @@ pub fn create_stream_channel_with_type( impl XtreamPlaylistItem { pub fn to_stream_channel(&self, target_id: u16) -> StreamChannel { + let title = if self.title.is_empty() { Arc::clone(&self.name) } else { Arc::clone(&self.title) }; StreamChannel { target_id, virtual_id: self.virtual_id, @@ -40,7 +41,7 @@ impl XtreamPlaylistItem { item_type: self.item_type, cluster: self.xtream_cluster, group: Arc::clone(&self.group), - title: Arc::clone(longest(&self.title, &self.name)), + title, url: Arc::clone(&self.url), shared: false, } @@ -49,6 +50,7 @@ impl XtreamPlaylistItem { impl M3uPlaylistItem { pub fn to_stream_channel(&self, target_id: u16) -> StreamChannel { + let title = if self.title.is_empty() { Arc::clone(&self.name) } else { Arc::clone(&self.title) }; StreamChannel { target_id, virtual_id: self.virtual_id, @@ -56,7 +58,7 @@ impl M3uPlaylistItem { item_type: self.item_type, cluster: XtreamCluster::try_from(self.item_type).unwrap_or(XtreamCluster::Live), group: Arc::clone(&self.group), - title: Arc::clone(longest(&self.title, &self.name)), + title, url: Arc::clone(&self.url), shared: false, }