diff --git a/backend/src/model/config/app.rs b/backend/src/model/config/app.rs index ef38976e8..f48d462d0 100644 --- a/backend/src/model/config/app.rs +++ b/backend/src/model/config/app.rs @@ -63,6 +63,41 @@ impl AppConfig { pub fn set_mappings(&self, mapping_path: &str, mappings_cfg: &Mappings) { self.set_mapping_path(Some(mapping_path)); let sources = self.sources.load(); + + // Warn only if mappings were actually loaded; target mapping stores still need updating below. + if !mappings_cfg.mappings.mapping.is_empty() { + // Collect all mapping_ids referenced by targets + let mut referenced_ids: HashSet<&str> = HashSet::new(); + for source in &sources.sources { + for target in &source.targets { + if let Some(ref ids) = target.mapping_ids { + for id in ids { + referenced_ids.insert(id.as_str()); + } + } + } + } + + // Warn about loaded-but-unreferenced mappings (may be intentional template/experiment files) + for mapping in &mappings_cfg.mappings.mapping { + if !referenced_ids.contains(mapping.id.as_str()) { + warn!( + "Mapping '{}' is loaded but not referenced by any target; it has no effect unless added to a target mapping list", + mapping.id + ); + } + } + + // Warn about mappings that have neither mapper nor counter + for mapping in &mappings_cfg.mappings.mapping { + let has_mapper = mapping.mapper.as_ref().is_some_and(|m| !m.is_empty()); + let has_counter = mapping.t_counter.as_ref().is_some_and(|c| !c.is_empty()); + if !has_mapper && !has_counter { + warn!("Mapping '{}' has neither mapper nor counter and has no effect", mapping.id); + } + } + } + for source in &sources.sources { for target in &source.targets { if let Some(mapping_ids) = &target.mapping_ids { @@ -486,3 +521,96 @@ impl AppConfig { self.media_tools.is_ffmpeg_available().await } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ConfigSource, Mapping, MappingDefinition, Mapper}; + use crate::utils::FileLockManager; + use shared::foundation::Filter; + use shared::model::{ConfigPaths, ProcessingOrder}; + + fn empty_paths() -> ConfigPaths { + ConfigPaths { + home_path: String::new(), + config_path: String::new(), + storage_path: String::new(), + config_file_path: String::new(), + sources_file_path: String::new(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: None, + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + } + } + + fn test_app_config_with_target(target: Arc) -> AppConfig { + AppConfig { + config: Arc::new(ArcSwap::from_pointee(Config::default())), + sources: Arc::new(ArcSwap::from_pointee(SourcesConfig { + sources: vec![ConfigSource { inputs: Vec::new(), targets: vec![target] }], + ..SourcesConfig::default() + })), + hdhomerun: Arc::new(ArcSwapOption::default()), + api_proxy: Arc::new(ArcSwapOption::default()), + file_locks: Arc::new(FileLockManager::default()), + paths: Arc::new(ArcSwap::from_pointee(empty_paths())), + custom_stream_response: Arc::new(ArcSwapOption::default()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(MediaToolCapabilities::new()), + } + } + + fn target_with_mapping_id(mapping_id: &str) -> Arc { + Arc::new(ConfigTarget { + id: 1, + enabled: true, + name: "target".to_string(), + options: None, + sort: None, + filter: Filter::default(), + output: Vec::new(), + rename: None, + mapping_ids: Some(vec![mapping_id.to_string()]), + mapping: Arc::new(ArcSwapOption::default()), + favourites: None, + processing_order: ProcessingOrder::Frm, + watch: None, + use_memory_cache: false, + }) + } + + fn mappings_with_one_mapper(mapping_id: &str) -> Mappings { + Mappings { + mappings: MappingDefinition { + templates: None, + mapping: vec![Mapping { + id: mapping_id.to_string(), + mapper: Some(vec![Mapper::default()]), + ..Mapping::default() + }], + }, + } + } + + fn empty_mappings() -> Mappings { + Mappings { + mappings: MappingDefinition { templates: None, mapping: Vec::new() }, + } + } + + #[test] + fn set_mappings_clears_existing_target_mappings_when_reload_is_empty() { + let target = target_with_mapping_id("map1"); + let app_config = test_app_config_with_target(Arc::clone(&target)); + + app_config.set_mappings("mappings", &mappings_with_one_mapper("map1")); + assert!(target.mapping.load().is_some(), "initial mapping registration should attach mapping to target"); + + app_config.set_mappings("mappings", &empty_mappings()); + assert!(target.mapping.load().is_none(), "empty mapping reload must clear stale target mappings"); + } +} diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index 3c9f3219a..e69e517ee 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -1631,7 +1631,7 @@ mod tests { use super::*; use crate::model::StagedInput; use shared::foundation::{get_filter, ValueProvider}; - use shared::model::{PlaylistItem, PlaylistItemHeader, PlaylistItemType}; + use shared::model::{PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster}; use shared::utils::Internable; fn item_with_props(props: StreamProperties) -> PlaylistItem { @@ -1848,4 +1848,141 @@ mod tests { assert!(filter.filter(&vod_provider)); assert!(!filter.filter(&live_provider)); } + + #[test] + fn assign_channel_no_playlist_preserves_non_zero_chno() { + let mut groups = vec![ + PlaylistGroup { + id: 1, + title: "Group A".intern(), + channels: vec![ + PlaylistItem { + header: PlaylistItemHeader { name: "A".intern(), chno: 10, ..Default::default() }, + }, + PlaylistItem { + header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() }, + }, + ], + xtream_cluster: XtreamCluster::Live, + }, + PlaylistGroup { + id: 2, + title: "Group C".intern(), + channels: vec![ + PlaylistItem { + header: PlaylistItemHeader { name: "C".intern(), chno: 1, ..Default::default() }, + }, + PlaylistItem { + header: PlaylistItemHeader { name: "D".intern(), chno: 0, ..Default::default() }, + }, + ], + xtream_cluster: XtreamCluster::Live, + }, + ]; + + assign_channel_no_playlist(&mut groups); + + // Non-zero chno values must be preserved + assert_eq!(groups[0].channels[0].header.chno, 10); + assert_eq!(groups[1].channels[0].header.chno, 1); + } + + #[test] + fn assign_channel_no_playlist_assigns_zero_chno_only() { + let mut groups = vec![ + PlaylistGroup { + id: 1, + title: "Group A".intern(), + channels: vec![ + PlaylistItem { + header: PlaylistItemHeader { name: "A".intern(), chno: 0, ..Default::default() }, + }, + PlaylistItem { + header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() }, + }, + PlaylistItem { + header: PlaylistItemHeader { name: "C".intern(), chno: 0, ..Default::default() }, + }, + ], + xtream_cluster: XtreamCluster::Live, + }, + ]; + + assign_channel_no_playlist(&mut groups); + + // All zero-chno channels should get assigned numbers starting at 1 + assert_eq!(groups[0].channels[0].header.chno, 1); + assert_eq!(groups[0].channels[1].header.chno, 2); + assert_eq!(groups[0].channels[2].header.chno, 3); + } + + #[test] + fn assign_channel_no_playlist_skips_existing_nonzero_numbers() { + let mut groups = vec![ + PlaylistGroup { + id: 1, + title: "Group A".intern(), + channels: vec![ + PlaylistItem { + header: PlaylistItemHeader { name: "A".intern(), chno: 5, ..Default::default() }, + }, + PlaylistItem { + header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() }, + }, + PlaylistItem { + header: PlaylistItemHeader { name: "C".intern(), chno: 2, ..Default::default() }, + }, + PlaylistItem { + header: PlaylistItemHeader { name: "D".intern(), chno: 0, ..Default::default() }, + }, + ], + xtream_cluster: XtreamCluster::Live, + }, + ]; + + assign_channel_no_playlist(&mut groups); + + // Existing non-zero numbers (2, 5) must be skipped when assigning new numbers + assert_eq!(groups[0].channels[0].header.chno, 5); // preserved + assert_eq!(groups[0].channels[2].header.chno, 2); // preserved + // B gets 1 (smallest available), D gets 3 (next available after 1 and existing 2) + assert_eq!(groups[0].channels[1].header.chno, 1); + assert_eq!(groups[0].channels[3].header.chno, 3); + } + + #[test] + fn assign_channel_no_playlist_assigns_following_group_order() { + let mut groups = vec![ + PlaylistGroup { + id: 1, + title: "Group 1".intern(), + channels: vec![ + PlaylistItem { + header: PlaylistItemHeader { name: "A".intern(), chno: 0, ..Default::default() }, + }, + PlaylistItem { + header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() }, + }, + ], + xtream_cluster: XtreamCluster::Live, + }, + PlaylistGroup { + id: 2, + title: "Group 2".intern(), + channels: vec![ + PlaylistItem { + header: PlaylistItemHeader { name: "C".intern(), chno: 0, ..Default::default() }, + }, + ], + xtream_cluster: XtreamCluster::Live, + }, + ]; + + assign_channel_no_playlist(&mut groups); + + // Numbers should follow iteration order across groups: A=1, B=2, C=3 + assert_eq!(groups[0].channels[0].header.chno, 1); + assert_eq!(groups[0].channels[1].header.chno, 2); + assert_eq!(groups[1].channels[0].header.chno, 3); + } } diff --git a/docs/src/configuration/mapping-dsl.md b/docs/src/configuration/mapping-dsl.md index 260242011..112af6dd9 100644 --- a/docs/src/configuration/mapping-dsl.md +++ b/docs/src/configuration/mapping-dsl.md @@ -277,6 +277,21 @@ mapping: | `concat` | *(Optional)* The separator string between the number and the original field for Prefix/Suffix (e.g., `". "`). | | `padding` | *(Optional)* Zero-padding length (e.g., `padding: 3` turns `1` into `001`). | +### Channel number timing + +`tvg-chno` is stored as `chno`. Mapper scripts run before final automatic channel +numbering, so `@chno` inside a mapper only contains the number from the source +playlist or a value already assigned by a previous mapping. If it is `0`, the +final generated channel number has not been assigned yet. + +After filtering, mapping, merging, and sorting, Tuliprox assigns missing channel +numbers only for channels where `chno == 0`. Existing non-zero channel numbers +are preserved. Counter mappings run after this automatic assignment and can be +used when numbering must follow the final output order. + +`tvg-id` remains the EPG channel ID (`epg_channel_id`) and should not be used +for channel numbering. + --- ## Advanced Examples diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 8a3674b82..2208b49ea 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -388,7 +388,7 @@ "STRIP": "Terms to ignore during EPG Smart Matching (e.g. '4K', 'HD', 'HEVC')." }, "EPG_SOURCE": { - "LOGO_OVERRIDE": "Overrides the channel logos provided by the EPG with the ones from the playlist or external mapping.", + "LOGO_OVERRIDE": "Replaces playlist/provider logos with icons from this XMLTV EPG source.", "PRIORITY": "Determines which EPG source is preferred if multiple sources provide data for the same channel.", "URL": "The URL of the XMLTV EPG file." }, diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index a3303c9fc..7db08459d 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -1478,4 +1478,91 @@ mod tests { let xtream_item = XtreamPlaylistItem::from(&item); assert_eq!(xtream_item.provider_id, 12345); } + + #[test] + fn m3u_to_m3u_emits_tvg_id_from_epg_channel_id() { + let item = M3uPlaylistItem { + virtual_id: 0, + provider_id: "prov1".intern(), + name: "Test Channel".intern(), + chno: 0, + logo: "".intern(), + logo_small: "".intern(), + group: "Test Group".intern(), + title: "Test Title".intern(), + parent_code: "".intern(), + audio_track: "".intern(), + time_shift: "".intern(), + rec: "".intern(), + url: "http://example.com/stream".intern(), + epg_channel_id: Some("epg_channel_123".intern()), + input_name: "test".intern(), + item_type: PlaylistItemType::Live, + t_stream_url: "".intern(), + t_resource_url: None, + source_ordinal: 0, + additional_properties: None, + }; + + let output = item.to_m3u(None, false); + assert!(output.contains(r#"tvg-id="epg_channel_123""#), "M3U output must contain tvg-id from epg_channel_id"); + } + + #[test] + fn m3u_to_m3u_emits_tvg_chno_from_chno() { + let item = M3uPlaylistItem { + virtual_id: 0, + provider_id: "prov1".intern(), + name: "Test Channel".intern(), + chno: 42, + logo: "".intern(), + logo_small: "".intern(), + group: "Test Group".intern(), + title: "Test Title".intern(), + parent_code: "".intern(), + audio_track: "".intern(), + time_shift: "".intern(), + rec: "".intern(), + url: "http://example.com/stream".intern(), + epg_channel_id: None, + input_name: "test".intern(), + item_type: PlaylistItemType::Live, + t_stream_url: "".intern(), + t_resource_url: None, + source_ordinal: 0, + additional_properties: None, + }; + + let output = item.to_m3u(None, false); + assert!(output.contains(r#"tvg-chno="42""#), "M3U output must contain tvg-chno from chno"); + } + + #[test] + fn m3u_to_m3u_omits_tvg_chno_when_chno_is_zero() { + let item = M3uPlaylistItem { + virtual_id: 0, + provider_id: "prov1".intern(), + name: "Test Channel".intern(), + chno: 0, + logo: "".intern(), + logo_small: "".intern(), + group: "Test Group".intern(), + title: "Test Title".intern(), + parent_code: "".intern(), + audio_track: "".intern(), + time_shift: "".intern(), + rec: "".intern(), + url: "http://example.com/stream".intern(), + epg_channel_id: None, + input_name: "test".intern(), + item_type: PlaylistItemType::Live, + t_stream_url: "".intern(), + t_resource_url: None, + source_ordinal: 0, + additional_properties: None, + }; + + let output = item.to_m3u(None, false); + assert!(!output.contains("tvg-chno"), "M3U output must not contain tvg-chno when chno is 0"); + } }