diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b20b25cb..9e22820ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ## ⚠️ Breaking Changes +- **`clear_invalid_epg_ids` moved to `epg_output.clear_invalid_ids`.** The target option now sits next to the other + EPG id output options. The top-level `options.clear_invalid_epg_ids` and its legacy alias `options.required_epg` are + rejected while loading. To migrate, move the value: + + ```yaml + options: + epg_output: + clear_invalid_ids: true + ``` + + In the Web UI the toggle is now part of the **EPG Output** group of the target options. + - **Recordings use the local API listener.** `api.host` must resolve locally to the listener; wildcard binds use loopback. `web_ui.player_server` and public `api-proxy.yml` servers no longer select recording capture URLs. Recording headers now apply to the @@ -1082,6 +1094,14 @@ ## 🐛 Fixes +- **Short EPG and player guides stay empty when playlist and XMLTV ids differ only in case.** A live channel whose + EPG id matches a guide channel case-insensitively (for example `usa4k.us` against ``) now + adopts the guide's spelling, so the playlist `tvg-id` / `epg_channel_id`, the XMLTV `` and the EPG db + key are identical. Previously the exact `get_short_epg` lookup and case-sensitive players missed these channels + unless `epg_output.lowercase_ids` was enabled. Affected ids change their visible case after the next target update. + The new target option `epg_output.adopt_guide_id_case` (default `true`, also in the Web UI target options) keeps + the playlist's original spelling when set to `false`. + - **Shared HLS retries an origin account reserved for another session.** A bound account that is briefly reserved by a competing session or an in-flight binding handoff is now retried within the existing acquire budget instead of failing the segment fetch immediately. Ownership checks and rebinding rules are unchanged; a permanently diff --git a/backend/app/src/api/endpoints/xmltv_api.rs b/backend/app/src/api/endpoints/xmltv_api.rs index a63fc644b..edadfdf25 100644 --- a/backend/app/src/api/endpoints/xmltv_api.rs +++ b/backend/app/src/api/endpoints/xmltv_api.rs @@ -1142,7 +1142,11 @@ mod tests { fn test_target_with_epg_options(lowercase_ids: bool, lowercase_display_names: bool) -> Arc { let mut target = test_target_with_xtream_only(); target.options = Some(ConfigTargetOptions { - epg_output: EpgOutputOptions { lowercase_ids, lowercase_xmltv_display_names: lowercase_display_names }, + epg_output: EpgOutputOptions { + lowercase_ids, + lowercase_xmltv_display_names: lowercase_display_names, + ..EpgOutputOptions::default() + }, ..ConfigTargetOptions::default() }); Arc::new(target) diff --git a/backend/processing/src/processor/epg.rs b/backend/processing/src/processor/epg.rs index a4dc7b658..463dbd955 100644 --- a/backend/processing/src/processor/epg.rs +++ b/backend/processing/src/processor/epg.rs @@ -2,7 +2,10 @@ use crate::{fetched_playlist::FetchedPlaylist, parser::xmltv::normalize_channel_ use log::{debug, trace, warn}; use rphonetic::{DoubleMetaphone, Encoder}; use shared::{ - model::{EpgNamePrefix, EpgSmartMatchConfigDto, PlaylistGroup, PlaylistItem, XtreamCluster}, + model::{ + ConfigTargetOptions, EpgChannel, EpgNamePrefix, EpgSmartMatchConfigDto, PlaylistGroup, PlaylistItem, + XtreamCluster, + }, utils::{Internable, CONSTANTS}, }; use std::{ @@ -17,6 +20,26 @@ use tuliprox_core::{ const MIN_FUZZY_SCORE_MARGIN: u16 = 3; +/// Target options that control how matched guide ids are written back to live channels. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EpgAssignOptions { + pub clear_invalid_epg_ids: bool, + pub adopt_guide_id_case: bool, +} + +impl Default for EpgAssignOptions { + fn default() -> Self { Self { clear_invalid_epg_ids: false, adopt_guide_id_case: true } } +} + +impl EpgAssignOptions { + pub fn from_target_options(options: Option<&ConfigTargetOptions>) -> Self { + options.map_or_else(Self::default, |options| Self { + clear_invalid_epg_ids: options.clear_invalid_epg_ids(), + adopt_guide_id_case: options.adopt_guide_epg_id_case(), + }) + } +} + #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] enum SmartMatchKind { Fuzzy, @@ -698,9 +721,26 @@ fn assign_smart_epg_id(chan: &mut PlaylistItem, id_cache: &EpgIdCache) -> Channe } } +/// Rewrites the channel's EPG id to the exact spelling of the matched guide channel. +/// +/// Guide matching is ASCII case-insensitive, but the EPG db is keyed by the guide channel +/// id and looked up exactly by the playlist id. Adopting the guide spelling keeps the +/// playlist `tvg-id`/`epg_channel_id`, the XMLTV `` and the db key identical. +fn adopt_guide_epg_id_case(chan: &mut PlaylistItem, guide_channels: &HashMap, &EpgChannel>) { + let guide_id = chan.header.epg_channel_id.as_deref().and_then(|current_id| { + with_folded_epg_id(current_id, |folded| guide_channels.get(folded)) + .filter(|guide| guide.id.as_ref() != current_id) + .map(|guide| Arc::clone(&guide.id)) + }); + if let Some(guide_id) = guide_id { + trace!("Adopted guide epg id casing {guide_id} for channel {}", chan.header.name); + chan.header.epg_channel_id = Some(guide_id); + } +} + fn assign_epg_icon( chan: &mut PlaylistItem, - icon_tags: &HashMap, &Arc>, + guide_channels: &HashMap, &EpgChannel>, icon_override_channels: &HashSet>, icon_assigned: &mut HashSet>, ) { @@ -714,7 +754,9 @@ fn assign_epg_icon( if icon_assigned.contains(folded_id) || !needs_icon { return; } - let Some(icon) = icon_tags.get(folded_id) else { + let Some(icon) = + guide_channels.get(folded_id).and_then(|guide| guide.icon.as_ref()).filter(|icon| !icon.is_empty()) + else { return; }; icon_assigned.insert(folded_id.intern()); @@ -739,18 +781,21 @@ fn has_processed_epg(item: &PlaylistItem, id_cache: &EpgIdCache) -> bool { fn assign_live_channel_epg( channel: &mut PlaylistItem, id_cache: &EpgIdCache, - icon_tags: &HashMap, &Arc>, + guide_channels: &HashMap, &EpgChannel>, icon_override_channels: &HashSet>, icon_assigned: &mut HashSet>, stats: &mut EpgAssignmentStats, - clear_invalid_epg_ids: bool, + options: EpgAssignOptions, ) { if id_cache.smart_match_enabled { stats.record(assign_smart_epg_id(channel, id_cache)); } let has_epg = has_processed_epg(channel, id_cache); - assign_epg_icon(channel, icon_tags, icon_override_channels, icon_assigned); - if clear_invalid_epg_ids && !has_epg { + if has_epg && options.adopt_guide_id_case { + adopt_guide_epg_id_case(channel, guide_channels); + } + assign_epg_icon(channel, guide_channels, icon_override_channels, icon_assigned); + if options.clear_invalid_epg_ids && !has_epg { channel.header.epg_channel_id = None; } } @@ -804,6 +849,25 @@ pub(crate) fn clear_invalid_live_epg_ids(fp: &mut FetchedPlaylist<'_>, epg: &[Ep } } +/// Re-adopts the guide id spelling for live channels whose ids were rewritten after EPG assignment, +/// e.g. by `AfterEpg` mappings, so the playlist id stays identical to the processed guide channel id. +pub(crate) fn adopt_guide_live_epg_id_case(fp: &mut FetchedPlaylist<'_>, epg: &[Epg]) { + if !fp.is_memory() { + return; + } + let guide_channels = epg + .iter() + .flat_map(|source| &source.children) + .map(|guide| (with_folded_epg_id(&guide.id, |folded| folded.intern()), guide.as_ref())) + .collect::, &EpgChannel>>(); + if guide_channels.is_empty() { + return; + } + fp.items_mut() + .filter(|item| is_live_epg_item(item)) + .for_each(|item| adopt_guide_epg_id_case(item, &guide_channels)); +} + /// Assigns EPG IDs and logos to live playlist channels by matching them with EPG data. /// Invalid EPG IDs are optionally cleared without removing playlist entries. /// @@ -821,7 +885,7 @@ async fn assign_channel_epg( new_epg: &mut Vec, fp: &mut FetchedPlaylist<'_>, id_cache: &mut EpgIdCache, - clear_invalid_epg_ids: bool, + options: EpgAssignOptions, ) { let Some(tv_guide) = &fp.epg else { return; @@ -830,24 +894,19 @@ async fn assign_channel_epg( let mut stats = EpgAssignmentStats::default(); if fp.is_memory() { - let icon_tags = merged_epg + let guide_channels = merged_epg .as_ref() .into_iter() .flat_map(|(epg_source, _)| &epg_source.children) - .filter_map(|tag| { - tag.icon - .as_ref() - .filter(|icon| !icon.is_empty()) - .map(|icon| (with_folded_epg_id(&tag.id, |folded| folded.intern()), icon)) - }) - .collect::, &Arc>>(); + .map(|guide| (with_folded_epg_id(&guide.id, |folded| folded.intern()), guide.as_ref())) + .collect::, &EpgChannel>>(); let icon_override_channels = merged_epg .as_ref() .into_iter() .flat_map(|(_, channels)| channels) .map(|id| with_folded_epg_id(id, |folded| folded.intern())) .collect::>(); - let mut icon_assigned = HashSet::with_capacity(icon_tags.len()); + let mut icon_assigned = HashSet::with_capacity(guide_channels.len()); let mut process_channel = |channel: &mut PlaylistItem| { if !is_live_epg_item(channel) { @@ -856,11 +915,11 @@ async fn assign_channel_epg( assign_live_channel_epg( channel, id_cache, - &icon_tags, + &guide_channels, &icon_override_channels, &mut icon_assigned, &mut stats, - clear_invalid_epg_ids, + options, ); }; @@ -895,7 +954,7 @@ async fn assign_channel_epg( /// let mut epg_data = Vec::new(); /// process_playlist_epg(&mut playlist, &mut epg_data, false); /// ``` -pub async fn process_playlist_epg(fp: &mut FetchedPlaylist<'_>, epg: &mut Vec, clear_invalid_epg_ids: bool) { +pub async fn process_playlist_epg(fp: &mut FetchedPlaylist<'_>, epg: &mut Vec, options: EpgAssignOptions) { if fp.input.epg.is_none() { return; } @@ -903,10 +962,10 @@ pub async fn process_playlist_epg(fp: &mut FetchedPlaylist<'_>, epg: &mut Vec + USA 4K + + Show + +"#; + let channels = [ + ("USA 4K", Some("usa4k.us")), + ("USA 4K HD", Some("USA4K.US")), + ("Already exact", Some("USA4K.us")), + ("Unknown", Some("unknown.id")), + ]; + for smart_matching in [false, true] { + let (assigned_ids, epg) = run_xmltv_matches(xmltv, &channels, smart_matching, false).await; + + assert_eq!(epg[0].children[0].id.as_ref(), "USA4K.us"); + assert_eq!( + assigned_ids.iter().map(Option::as_deref).collect::>(), + vec![Some("USA4K.us"), Some("USA4K.us"), Some("USA4K.us"), Some("unknown.id")], + "smart_matching={smart_matching}" + ); + } + }); + } + + #[test] + fn post_mapping_adoption_restores_guide_channel_casing() { + let input = ConfigInput::from(ConfigInputDto::default()); + let groups = vec![PlaylistGroup { + id: 1, + title: "Live".intern(), + channels: vec![ + live_playlist_item("USA 4K", Some("usa4k.us")), + live_playlist_item("Unknown", Some("unknown.id")), + ], + xtream_cluster: super::XtreamCluster::Live, + }]; + let mut playlist = + FetchedPlaylist { input: &input, source: MemoryPlaylistSource::new(groups).into_source(), epg: None }; + let epg = vec![super::Epg { + priority: 0, + logo_override: false, + attributes: None, + children: vec![Arc::new(shared::model::EpgChannel::new("USA4K.us".intern()))], + }]; + + super::adopt_guide_live_epg_id_case(&mut playlist, &epg); + + let ids = playlist.items_mut().map(|item| item.header.epg_channel_id.clone()).collect::>(); + assert_eq!(ids.iter().map(Option::as_deref).collect::>(), vec![Some("USA4K.us"), Some("unknown.id")]); + } + + #[test] + fn disabled_guide_casing_adoption_keeps_playlist_epg_ids() { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async move { + let dir = tempdir().unwrap(); + let epg_path = dir.path().join("keep-case.xml"); + fs::write( + &epg_path, + r#" + USA 4K + + Show + +"#, + ) + .unwrap(); + let mut input = ConfigInput::from(ConfigInputDto::default()); + input.epg = Some(EpgConfig { sources: vec![], smart_match: None }); + let groups = vec![PlaylistGroup { + id: 1, + title: "Live".intern(), + channels: vec![live_playlist_item("USA 4K", Some("usa4k.us"))], + xtream_cluster: super::XtreamCluster::Live, + }]; + let mut playlist = FetchedPlaylist { + input: &input, + source: MemoryPlaylistSource::new(groups).into_source(), + epg: Some(TVGuide::new(vec![PersistedEpgSource { + file_path: epg_path, + priority: 0, + logo_override: false, + kind: PersistedEpgSourceKind::Xmltv, + }])), + }; + let mut epg = Vec::new(); + let options = super::EpgAssignOptions { adopt_guide_id_case: false, ..super::EpgAssignOptions::default() }; + + super::process_playlist_epg(&mut playlist, &mut epg, options).await; + + let updated = playlist.items_mut().next().unwrap(); + assert_eq!(updated.header.epg_channel_id.as_deref(), Some("usa4k.us")); + assert_eq!(epg[0].children[0].id.as_ref(), "USA4K.us"); + }); + } + #[test] fn clear_invalid_epg_ids_preserves_items_and_clears_only_unmatched_live_ids() { let runtime = tokio::runtime::Runtime::new().unwrap(); @@ -1335,7 +1501,12 @@ mod tests { }; let mut epg = Vec::new(); - super::process_playlist_epg(&mut playlist, &mut epg, true).await; + super::process_playlist_epg( + &mut playlist, + &mut epg, + super::EpgAssignOptions { clear_invalid_epg_ids: true, ..super::EpgAssignOptions::default() }, + ) + .await; let items = playlist .items_mut() @@ -1386,7 +1557,12 @@ mod tests { let mut playlist = FetchedPlaylist { input: &input, source: MemoryPlaylistSource::new(groups).into_source(), epg: None }; - super::process_playlist_epg(&mut playlist, &mut Vec::new(), true).await; + super::process_playlist_epg( + &mut playlist, + &mut Vec::new(), + super::EpgAssignOptions { clear_invalid_epg_ids: true, ..super::EpgAssignOptions::default() }, + ) + .await; assert_eq!(playlist.items_mut().count(), 1); assert_eq!( @@ -1441,7 +1617,12 @@ mod tests { epg: Some(tv_guide), }; - super::process_playlist_epg(&mut playlist, &mut Vec::new(), false).await; + super::process_playlist_epg( + &mut playlist, + &mut Vec::new(), + super::EpgAssignOptions { clear_invalid_epg_ids: false, ..super::EpgAssignOptions::default() }, + ) + .await; assert_eq!(playlist.get_group_count(), 2); }); @@ -1475,7 +1656,12 @@ mod tests { epg: Some(tv_guide), }; - super::process_playlist_epg(&mut playlist, &mut Vec::new(), true).await; + super::process_playlist_epg( + &mut playlist, + &mut Vec::new(), + super::EpgAssignOptions { clear_invalid_epg_ids: true, ..super::EpgAssignOptions::default() }, + ) + .await; assert_eq!(playlist.items_mut().count(), 1); assert_eq!(playlist.get_group_count(), 1); @@ -1618,7 +1804,12 @@ mod tests { }; let mut epg = Vec::new(); - super::process_playlist_epg(&mut playlist, &mut epg, false).await; + super::process_playlist_epg( + &mut playlist, + &mut epg, + super::EpgAssignOptions { clear_invalid_epg_ids: false, ..super::EpgAssignOptions::default() }, + ) + .await; assert_eq!(epg.len(), 1); assert_eq!(epg[0].children[0].id.as_ref(), "f1.calendar"); @@ -1656,7 +1847,12 @@ mod tests { }; let mut epg = Vec::new(); - super::process_playlist_epg(&mut playlist, &mut epg, false).await; + super::process_playlist_epg( + &mut playlist, + &mut epg, + super::EpgAssignOptions { clear_invalid_epg_ids: false, ..super::EpgAssignOptions::default() }, + ) + .await; let updated = playlist.items_mut().next().unwrap(); assert_eq!(updated.header.epg_channel_id.as_deref(), Some("f1.calendar")); diff --git a/backend/processing/src/processor/playlist/mod.rs b/backend/processing/src/processor/playlist/mod.rs index 185198724..4cfb389b2 100644 --- a/backend/processing/src/processor/playlist/mod.rs +++ b/backend/processing/src/processor/playlist/mod.rs @@ -6,7 +6,10 @@ use crate::{ parser::xmltv::{flatten_tvguide, merge_epg_trees, EpgMergeAccumulator}, playlist_watch::{process_group_watch, process_target_groups_watch}, processor::{ - epg::{clear_invalid_live_epg_ids, process_playlist_epg, retain_epg_referenced_by_groups}, + epg::{ + adopt_guide_live_epg_id_case, clear_invalid_live_epg_ids, process_playlist_epg, + retain_epg_referenced_by_groups, EpgAssignOptions, + }, sort::sort_playlist, xtream_series::playlist_resolve_series, xtream_vod::playlist_resolve_vod, diff --git a/backend/processing/src/processor/playlist/target.rs b/backend/processing/src/processor/playlist/target.rs index 2fd5596a2..3d2e5ec6e 100644 --- a/backend/processing/src/processor/playlist/target.rs +++ b/backend/processing/src/processor/playlist/target.rs @@ -314,7 +314,8 @@ pub(crate) async fn prepare_playlist_for_target 100`, `Chno >= 100`, `Chno < 200`, `Chno <= 200` @@ -1606,8 +1607,9 @@ targets: use_output: xtream options: ignore_logo: false - clear_invalid_epg_ids: false epg_output: + clear_invalid_ids: false + adopt_guide_id_case: true lowercase_ids: true lowercase_xmltv_display_names: false share_live_streams: @@ -1625,11 +1627,12 @@ targets: | Parameter | Type | Required | Default | Technical Impact & Background | |:-------------------------------------------|:-----|:--------:|:--------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `ignore_logo` | Bool | No | `false` | Ignores `tvg-logo` and `tvg-logo-small` attributes. This reduces downstream device-side logo caching and can keep generated M3U playlists leaner for clients with limited storage or poor cache invalidation behavior. | -| `clear_invalid_epg_ids` | Bool | No | `false` | Clears an EPG ID when it does not resolve to the processed EPG data. Playlist entries are never removed. Smart matching runs first, and IDs introduced or changed by later mappings are validated again before the `persist` filter. The legacy input name `required_epg` is still accepted, but configuration is serialized with the new name. | | `share_live_streams.hls` | Bool | No | `false` | Enables HLS live sharing for the new HLS cache proxy path. This is a configuration switch for the HLS cache feature and is independent from MPEG-TS stream sharing. | | `share_live_streams.mpeg_ts` | Bool | No | `false` | Allows Tuliprox to share MPEG-TS live stream connections in reverse proxy mode. This can reduce upstream provider connection usage when multiple clients watch the same channel, but it increases memory usage per shared channel. | | `remove_duplicates` | Bool | No | `false` | Legacy pre-transform identity deduplication. It runs independently for each input before the F/R/M pipe and removes repeated source identities before mapping can emit additional items. The field remains supported for backward compatibility. | | `deduplicate` | Map | No | - | Post-merge, quality-aware content deduplication. It runs after all inputs have been transformed and merged. Channels whose match value is identical after stripping quality tokens (`4K`, `UHD`, `2160p`, `QHD`, `1440p`, `FHD`, `1080p`, `HD`, `720p`, `SD`, `480p`, `576p`) collapse to one entry. Sub-keys: `match_by` (`caption` (default), `name`, `title`), `keep` (`best_quality` (default), `first`) and `match_as_ascii` (default `false`). Matching is per cluster across all groups; ties keep the first occurrence. This lets mappings affect the final duplicate comparison results. | +| `epg_output.clear_invalid_ids` | Bool | No | `false` | Clears an EPG ID when it does not resolve to the processed EPG data. Playlist entries are never removed. Smart matching runs first, and IDs introduced or changed by later mappings are validated again before the `persist` filter. | +| `epg_output.adopt_guide_id_case` | Bool | No | `true` | Rewrites a live channel EPG id that matches a guide channel only case-insensitively (for example `usa4k.us` against ``) to the guide's exact spelling, so the M3U `tvg-id`, Xtream `epg_channel_id`, XMLTV `` and EPG API lookups use the same id. Set `false` to keep the playlist's original spelling. Changing this option requires a full target refresh. | | `epg_output.lowercase_ids` | Bool | No | `false` | Canonicalizes visible technical EPG IDs with ASCII lowercase across M3U `tvg-id`, Xtream `epg_channel_id`, XMLTV channel/programme references, and EPG API responses. Changing this option requires a full target refresh. | | `epg_output.lowercase_xmltv_display_names` | Bool | No | `false` | Applies Unicode lowercase exclusively to XMLTV `` values during serialization. Playlist names, Xtream names, programme titles, and programme descriptions remain unchanged; persisted target data does not require rebuilding. | | `force_redirect` | Bool | No | `false` | Optional redirect-related behavior switch. This influences how Tuliprox serves final stream delivery where redirect-style output handling is required by the deployment model. | @@ -1672,8 +1675,9 @@ targets: #### EPG Output Normalization -`epg_output` applies to the entire target so every output format uses the same EPG identity space. Both options -default to `false`; when they are omitted, Tuliprox preserves the source casing and existing output behavior. +`epg_output` applies to the entire target so every output format uses the same EPG identity space. The two +lowercase options default to `false`. `adopt_guide_id_case` defaults to `true`, so even when every option is omitted, +a live channel id that matches a guide channel only case-insensitively is rewritten to the guide id's exact casing. ```yaml targets: diff --git a/frontend/public/assets/i18n/ar.json b/frontend/public/assets/i18n/ar.json index 979ae54ec..5b5aa33da 100644 --- a/frontend/public/assets/i18n/ar.json +++ b/frontend/public/assets/i18n/ar.json @@ -386,10 +386,11 @@ "WATCH": "قائمة أنماط المجموعات لمراقبة التغييرات. يتم إرسال الإشعارات عبر المراسلة المكوّنة." }, "CONFIG_TARGET_OPTIONS": { - "CLEAR_INVALID_EPG_IDS": "يمسح معرفات EPG التي لا تطابق بيانات EPG المعالجة دون إزالة عناصر قائمة التشغيل.", "SHARE_LIVE_STREAMS": "يُفعّل مشاركة البث المباشر. المفتاح الرئيسي يضبط HLS و MPEG-TS معًا؛ يمكن تغيير كل تنسيق أيضًا بشكل منفصل." }, "EPG_OUTPUT_OPTIONS": { + "CLEAR_INVALID_IDS": "يمسح معرفات EPG التي لا تطابق بيانات EPG المعالجة دون إزالة عناصر قائمة التشغيل.", + "ADOPT_GUIDE_ID_CASE": "يعيد كتابة معرّف EPG في قائمة التشغيل الذي يطابق قناة الدليل دون مراعاة حالة الأحرف إلى معرّف قناة الدليل الدقيق، لتبقى معرّفات قائمة التشغيل وXMLTV واستعلامات واجهة EPG متطابقة. مفعّل افتراضيًا؛ يلزم تحديث كامل للوجهة بعد تغيير هذا الخيار.", "LOWERCASE_IDS": "يحوّل معرّفات EPG التقنية إلى أحرف ASCII صغيرة في مخرجات M3U وXtream وXMLTV وواجهة EPG. يلزم تحديث كامل للوجهة بعد تغيير هذا الخيار.", "LOWERCASE_XMLTV_DISPLAY_NAMES": "يحوّل نص أسماء العرض في XMLTV فقط إلى أحرف Unicode صغيرة. تبقى أسماء قوائم التشغيل وعناوين البرامج دون تغيير." }, @@ -1210,6 +1211,7 @@ "LOG_ACTIVE_USER": "تسجيل المستخدم النشط", "LOG_CONFIG": "سجل", "LOG_LEVEL": "مستوى السجل", + "ADOPT_GUIDE_EPG_ID_CASE": "اعتماد حالة أحرف معرّف EPG من الدليل", "LOWERCASE_EPG_IDS": "معرّفات EPG بأحرف صغيرة", "LOWERCASE_XMLTV_DISPLAY_NAMES": "أسماء عرض XMLTV بأحرف صغيرة", "M3U": "m3u", diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 872586a1e..b7cc55d4d 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -386,10 +386,11 @@ "WATCH": "List of group patterns to monitor for changes. Notifications are sent via configured messaging." }, "CONFIG_TARGET_OPTIONS": { - "CLEAR_INVALID_EPG_IDS": "Clears EPG IDs that do not match the processed EPG data. Playlist entries are not removed.", "SHARE_LIVE_STREAMS": "Enables live stream sharing. The master toggle sets HLS and MPEG-TS together; each format can also be changed separately." }, "EPG_OUTPUT_OPTIONS": { + "CLEAR_INVALID_IDS": "Clears EPG IDs that do not match the processed EPG data. Playlist entries are not removed.", + "ADOPT_GUIDE_ID_CASE": "Rewrites a playlist EPG id that matches a guide channel only case-insensitively to the guide's exact channel id, so playlist ids, XMLTV and EPG API lookups stay identical. Enabled by default; a full target refresh is required after changing this option.", "LOWERCASE_IDS": "Canonicalizes technical EPG IDs to ASCII lowercase across M3U, Xtream, XMLTV, and EPG API output. A full target refresh is required after changing this option.", "LOWERCASE_XMLTV_DISPLAY_NAMES": "Converts only XMLTV display-name text to Unicode lowercase. Playlist names and programme titles remain unchanged." }, @@ -1211,6 +1212,7 @@ "LOG_ACTIVE_USER": "Log Active User", "LOG_CONFIG": "Log", "LOG_LEVEL": "Log Level", + "ADOPT_GUIDE_EPG_ID_CASE": "Adopt guide EPG ID case", "LOWERCASE_EPG_IDS": "Lowercase EPG IDs", "LOWERCASE_XMLTV_DISPLAY_NAMES": "Lowercase XMLTV display names", "M3U": "m3u", diff --git a/frontend/public/assets/i18n/es.json b/frontend/public/assets/i18n/es.json index 3ce7476ec..4ac2ce151 100644 --- a/frontend/public/assets/i18n/es.json +++ b/frontend/public/assets/i18n/es.json @@ -386,10 +386,11 @@ "WATCH": "Lista de patrones de grupo a monitorizar para detectar cambios. Las notificaciones se envían mediante la mensajería configurada." }, "CONFIG_TARGET_OPTIONS": { - "CLEAR_INVALID_EPG_IDS": "Borra los ID de EPG que no coinciden con los datos de EPG procesados. Las entradas de la lista de reproducción no se eliminan.", "SHARE_LIVE_STREAMS": "Habilita el uso compartido de streams en directo. El interruptor maestro activa HLS y MPEG-TS juntos; cada formato también se puede cambiar por separado." }, "EPG_OUTPUT_OPTIONS": { + "CLEAR_INVALID_IDS": "Borra los ID de EPG que no coinciden con los datos de EPG procesados. Las entradas de la lista de reproducción no se eliminan.", + "ADOPT_GUIDE_ID_CASE": "Reescribe un ID de EPG de la lista que coincide con un canal de la guía solo sin distinguir mayúsculas con el ID exacto del canal de la guía, para que los ID de la lista, XMLTV y las consultas de la API de EPG sean idénticos. Activado por defecto; se requiere una actualización completa del destino tras cambiar esta opción.", "LOWERCASE_IDS": "Canoniza los ID técnicos de EPG a minúsculas ASCII en la salida M3U, Xtream, XMLTV y la API de EPG. Se requiere una actualización completa del destino tras cambiar esta opción.", "LOWERCASE_XMLTV_DISPLAY_NAMES": "Convierte solo el texto de display-name de XMLTV a minúsculas Unicode. Los nombres de la lista de reproducción y los títulos de programas no se modifican." }, @@ -1211,6 +1212,7 @@ "LOG_ACTIVE_USER": "Registrar usuario activo", "LOG_CONFIG": "Registro", "LOG_LEVEL": "Nivel de registro", + "ADOPT_GUIDE_EPG_ID_CASE": "Adoptar mayúsculas del ID de la guía EPG", "LOWERCASE_EPG_IDS": "ID de EPG en minúsculas", "LOWERCASE_XMLTV_DISPLAY_NAMES": "Nombres de visualización XMLTV en minúsculas", "M3U": "m3u", diff --git a/frontend/public/assets/i18n/ru.json b/frontend/public/assets/i18n/ru.json index 22dfbd5c9..b94f14614 100644 --- a/frontend/public/assets/i18n/ru.json +++ b/frontend/public/assets/i18n/ru.json @@ -386,10 +386,11 @@ "WATCH": "Список шаблонов групп для мониторинга изменений. Уведомления отправляются через настроенный мессенджер." }, "CONFIG_TARGET_OPTIONS": { - "CLEAR_INVALID_EPG_IDS": "Очищает идентификаторы EPG, не совпадающие с обработанными данными EPG, не удаляя элементы плейлиста.", "SHARE_LIVE_STREAMS": "Включает совместное использование прямых трансляций. Главный переключатель задаёт HLS и MPEG-TS вместе; каждый формат можно изменить и отдельно." }, "EPG_OUTPUT_OPTIONS": { + "CLEAR_INVALID_IDS": "Очищает идентификаторы EPG, не совпадающие с обработанными данными EPG, не удаляя элементы плейлиста.", + "ADOPT_GUIDE_ID_CASE": "Заменяет идентификатор EPG плейлиста, совпадающий с каналом гида только без учёта регистра, на точный идентификатор канала гида, чтобы идентификаторы плейлиста, XMLTV и запросы API EPG совпадали. Включено по умолчанию; после изменения этой опции требуется полное обновление целевого плейлиста.", "LOWERCASE_IDS": "Преобразует технические идентификаторы EPG в нижний регистр ASCII во всех выходах M3U, Xtream, XMLTV и API EPG. После изменения этой опции требуется полное обновление целевого плейлиста.", "LOWERCASE_XMLTV_DISPLAY_NAMES": "Преобразует в нижний регистр Unicode только текст display-name XMLTV. Имена плейлиста и названия программ остаются без изменений." }, @@ -1210,6 +1211,7 @@ "LOG_ACTIVE_USER": "Логировать активного пользователя", "LOG_CONFIG": "Лог", "LOG_LEVEL": "Уровень логирования", + "ADOPT_GUIDE_EPG_ID_CASE": "Регистр идентификатора EPG из гида", "LOWERCASE_EPG_IDS": "Идентификаторы EPG в нижнем регистре", "LOWERCASE_XMLTV_DISPLAY_NAMES": "Отображаемые имена XMLTV в нижнем регистре", "M3U": "m3u", diff --git a/frontend/src/app/components/config/api_config_view.rs b/frontend/src/app/components/config/api_config_view.rs index 6626c02aa..f492ffab4 100644 --- a/frontend/src/app/components/config/api_config_view.rs +++ b/frontend/src/app/components/config/api_config_view.rs @@ -488,13 +488,11 @@ pub fn ApiConfigView() -> Html { { if form_state_api_proxy_config.form.server.is_empty() { html! { - - - } + } } else { html! { definition={table_definition.clone()} /> } } diff --git a/frontend/src/app/components/config/plans_view.rs b/frontend/src/app/components/config/plans_view.rs index 10de2dd9b..cf95689de 100644 --- a/frontend/src/app/components/config/plans_view.rs +++ b/frontend/src/app/components/config/plans_view.rs @@ -467,12 +467,10 @@ pub fn PlansView() -> Html { { if plans.is_empty() { html! { - - } } else { html! { definition={table_definition.clone()} /> } diff --git a/frontend/src/app/components/config/schedules_config_view.rs b/frontend/src/app/components/config/schedules_config_view.rs index 70c5f3d86..3fdec0d73 100644 --- a/frontend/src/app/components/config/schedules_config_view.rs +++ b/frontend/src/app/components/config/schedules_config_view.rs @@ -66,8 +66,6 @@ pub fn SchedulesConfigView() -> Html { let translate = use_translation(); let view_columns = use_memo((), |()| schedule_columns(false)); let edit_columns = use_memo((), |()| schedule_columns(true)); - let content_columns = - use_memo((), |()| ScheduleColumn::columns().into_iter().filter(|column| column.content).collect::>()); let services_ctx = use_service_context(); let config_ctx = use_context::().expect("Config context not found"); let config_view_ctx = use_context::().expect("ConfigViewContext not found"); @@ -353,12 +351,10 @@ pub fn SchedulesConfigView() -> Html { } } None => html! { - - }, }; diff --git a/frontend/src/app/components/recording/recording_library_view.rs b/frontend/src/app/components/recording/recording_library_view.rs index be364c0d3..4ee1aaff1 100644 --- a/frontend/src/app/components/recording/recording_library_view.rs +++ b/frontend/src/app/components/recording/recording_library_view.rs @@ -668,9 +668,7 @@ pub fn recording_library_view(props: &RecordingLibraryViewProps) -> Html { if unavailable_code.is_some() { } else if tasks_state.is_empty() { - - - + } else { definition={table_definition} /> } diff --git a/frontend/src/app/components/source_editor/target_form.rs b/frontend/src/app/components/source_editor/target_form.rs index 6ba1e052e..d970e13c2 100644 --- a/frontend/src/app/components/source_editor/target_form.rs +++ b/frontend/src/app/components/source_editor/target_form.rs @@ -42,6 +42,7 @@ const LABEL_MPEG_TS: &str = "LABEL.MPEG_TS"; const LABEL_REMOVE_DUPLICATES: &str = "LABEL.REMOVE_DUPLICATES"; const LABEL_FORCE_REDIRECT: &str = "LABEL.FORCE_REDIRECT"; const LABEL_EPG_OUTPUT: &str = "LABEL.EPG_OUTPUT"; +const LABEL_ADOPT_GUIDE_EPG_ID_CASE: &str = "LABEL.ADOPT_GUIDE_EPG_ID_CASE"; const LABEL_LOWERCASE_EPG_IDS: &str = "LABEL.LOWERCASE_EPG_IDS"; const LABEL_LOWERCASE_XMLTV_DISPLAY_NAMES: &str = "LABEL.LOWERCASE_XMLTV_DISPLAY_NAMES"; const LABEL_MAIN: &str = "LABEL.MAIN_CONFIG"; @@ -140,6 +141,7 @@ pub enum ConfigTargetOptionsFormAction { ShareLiveStreamsHls(bool), ShareLiveStreamsMpegTs(bool), RemoveDuplicates(bool), + AdoptGuideEpgIdCase(bool), LowercaseEpgIds(bool), LowercaseXmltvDisplayNames(bool), ForceRedirect(Option), @@ -159,7 +161,7 @@ impl yew::prelude::Reducible for ConfigTargetOptionsFormState { modified = true; } ConfigTargetOptionsFormAction::ClearInvalidEpgIds(value) => { - form.clear_invalid_epg_ids = value; + form.epg_output.clear_invalid_ids = value; modified = true; } ConfigTargetOptionsFormAction::ShareLiveStreamsHls(value) => { @@ -174,6 +176,10 @@ impl yew::prelude::Reducible for ConfigTargetOptionsFormState { form.remove_duplicates = value; modified = true; } + ConfigTargetOptionsFormAction::AdoptGuideEpgIdCase(value) => { + form.epg_output.adopt_guide_id_case = value; + modified = true; + } ConfigTargetOptionsFormAction::LowercaseEpgIds(value) => { form.epg_output.lowercase_ids = value; modified = true; @@ -325,32 +331,50 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { } }; - let render_epg_output = - |readonly: bool, lowercase_ids_on_change: Callback, lowercase_names_on_change: Callback| { - html! { -
-
- { translate.t(LABEL_EPG_OUTPUT) } -
-
- { target_option_toggle( - translate.t(LABEL_LOWERCASE_EPG_IDS), - "EPG_OUTPUT_OPTIONS.LOWERCASE_IDS", - target_options_state.form.epg_output.lowercase_ids, - readonly, - lowercase_ids_on_change, - ) } - { target_option_toggle( - translate.t(LABEL_LOWERCASE_XMLTV_DISPLAY_NAMES), - "EPG_OUTPUT_OPTIONS.LOWERCASE_XMLTV_DISPLAY_NAMES", - target_options_state.form.epg_output.lowercase_xmltv_display_names, - readonly, - lowercase_names_on_change, - ) } -
+ let epg_output_on_change = |action: fn(bool) -> ConfigTargetOptionsFormAction| { + let target_options_state = target_options_state.clone(); + Callback::from(move |value| target_options_state.dispatch(action(value))) + }; + let render_epg_output = |readonly: bool| { + let epg_output = &target_options_state.form.epg_output; + html! { +
+
+ { translate.t(LABEL_EPG_OUTPUT) }
- } - }; +
+ { target_option_toggle( + translate.t(LABEL_CLEAR_INVALID_EPG_IDS), + "EPG_OUTPUT_OPTIONS.CLEAR_INVALID_IDS", + epg_output.clear_invalid_ids, + readonly, + epg_output_on_change(ConfigTargetOptionsFormAction::ClearInvalidEpgIds), + ) } + { target_option_toggle( + translate.t(LABEL_ADOPT_GUIDE_EPG_ID_CASE), + "EPG_OUTPUT_OPTIONS.ADOPT_GUIDE_ID_CASE", + epg_output.adopt_guide_id_case, + readonly, + epg_output_on_change(ConfigTargetOptionsFormAction::AdoptGuideEpgIdCase), + ) } + { target_option_toggle( + translate.t(LABEL_LOWERCASE_EPG_IDS), + "EPG_OUTPUT_OPTIONS.LOWERCASE_IDS", + epg_output.lowercase_ids, + readonly, + epg_output_on_change(ConfigTargetOptionsFormAction::LowercaseEpgIds), + ) } + { target_option_toggle( + translate.t(LABEL_LOWERCASE_XMLTV_DISPLAY_NAMES), + "EPG_OUTPUT_OPTIONS.LOWERCASE_XMLTV_DISPLAY_NAMES", + epg_output.lowercase_xmltv_display_names, + readonly, + epg_output_on_change(ConfigTargetOptionsFormAction::LowercaseXmltvDisplayNames), + ) } +
+
+ } + }; if props.allow_write { let share_live_hls_on_change = { let target_options_state = target_options_state.clone(); @@ -364,23 +388,11 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { target_options_state.dispatch(ConfigTargetOptionsFormAction::ShareLiveStreamsMpegTs(value)); }) }; - let lowercase_epg_ids_on_change = { - let target_options_state = target_options_state.clone(); - Callback::from(move |value| { - target_options_state.dispatch(ConfigTargetOptionsFormAction::LowercaseEpgIds(value)); - }) - }; - let lowercase_xmltv_display_names_on_change = { - let target_options_state = target_options_state.clone(); - Callback::from(move |value| { - target_options_state.dispatch(ConfigTargetOptionsFormAction::LowercaseXmltvDisplayNames(value)); - }) - }; html! {
{ edit_field_bool!(target_options_state, translate.t(LABEL_IGNORE_LOGO), ignore_logo, ConfigTargetOptionsFormAction::IgnoreLogo) } - { edit_field_bool!(target_options_state, translate.t(LABEL_CLEAR_INVALID_EPG_IDS), clear_invalid_epg_ids, ConfigTargetOptionsFormAction::ClearInvalidEpgIds) } + { edit_field_bool!(target_options_state, translate.t(LABEL_REMOVE_DUPLICATES), remove_duplicates, ConfigTargetOptionsFormAction::RemoveDuplicates) }
{ translate.t(LABEL_SHARE_LIVE_STREAMS) } @@ -402,12 +414,7 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { ) }
- { edit_field_bool!(target_options_state, translate.t(LABEL_REMOVE_DUPLICATES), remove_duplicates, ConfigTargetOptionsFormAction::RemoveDuplicates) } - { render_epg_output( - false, - lowercase_epg_ids_on_change, - lowercase_xmltv_display_names_on_change, - ) } + { render_epg_output(false) }
{ translate.t(LABEL_FORCE_REDIRECT) } @@ -433,7 +440,7 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html {
{ config_field_bool!(target_options_state.form, translate.t(LABEL_IGNORE_LOGO), ignore_logo) } - { config_field_bool!(target_options_state.form, translate.t(LABEL_CLEAR_INVALID_EPG_IDS), clear_invalid_epg_ids) } + { config_field_bool!(target_options_state.form, translate.t(LABEL_REMOVE_DUPLICATES), remove_duplicates) }
{ translate.t(LABEL_SHARE_LIVE_STREAMS) } @@ -455,8 +462,7 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { ) }
- { config_field_bool!(target_options_state.form, translate.t(LABEL_REMOVE_DUPLICATES), remove_duplicates) } - { render_epg_output(true, Callback::noop(), Callback::noop()) } + { render_epg_output(true) }
{ translate.t(LABEL_FORCE_REDIRECT) } @@ -749,11 +755,23 @@ mod tests { assert!(!state.form.epg_output.lowercase_xmltv_display_names); } + #[test] + fn adopt_guide_epg_id_case_defaults_on_and_action_updates_nested_option() { + let state = default_options_state(); + assert!(state.form.epg_output.adopt_guide_id_case); + + let state = state.reduce(ConfigTargetOptionsFormAction::AdoptGuideEpgIdCase(false)); + + assert!(!state.form.epg_output.adopt_guide_id_case); + assert!(state.modified); + assert!(!state.form.epg_output.lowercase_ids); + } + #[test] fn clear_invalid_epg_ids_action_updates_target_option() { let state = default_options_state().reduce(ConfigTargetOptionsFormAction::ClearInvalidEpgIds(true)); - assert!(state.form.clear_invalid_epg_ids); + assert!(state.form.epg_output.clear_invalid_ids); assert!(state.modified); } diff --git a/frontend/src/app/components/table/csv.rs b/frontend/src/app/components/table/csv.rs index 40512042c..461842694 100644 --- a/frontend/src/app/components/table/csv.rs +++ b/frontend/src/app/components/table/csv.rs @@ -68,7 +68,7 @@ pub fn CsvTable(props: &CsvTableProps) -> Html { let data = if props.first_row_is_header { rows.get(1..).unwrap_or_default() } else { rows.as_slice() }; let table_class = props.class.clone().unwrap_or_else(|| "tp__csv-table__table".into()); html! {
- + {for visible.iter().map(|index| { let column = &columns[*index]; html! { } })} diff --git a/frontend/src/app/components/table/mod.browser.test.rs b/frontend/src/app/components/table/mod.browser.test.rs index dfa06300b..e4178fed2 100644 --- a/frontend/src/app/components/table/mod.browser.test.rs +++ b/frontend/src/app/components/table/mod.browser.test.rs @@ -263,3 +263,53 @@ async fn pagination_normalizes_pages_and_bounds_navigation_callbacks() -> Result } Ok(()) } + +#[component] +fn EmptyToggleHarness() -> Html { + let state = use_reducer(|| UserSettingsState { ready: true, ..Default::default() }); + let filled = use_state_eq(|| false); + let toggle = { + let filled = filled.clone(); + Callback::from(move |_| filled.set(!*filled)) + }; + let definition = Rc::new(TableDefinition { + items: Some(Rc::new(if *filled { vec![Rc::new(7)] } else { Vec::new() })), + table_id: "empty-test".into(), + columns: Rc::new(vec![TableColumn::new("value", "Value")]), + row_key: Callback::from(|(_, item): (usize, Rc)| AttrValue::from(item.to_string())), + render_header_cell: Callback::from(|_| html! {"Value"}), + render_data_cell: Callback::from(|(_, _, item): (usize, usize, Rc)| html! {*item}), + on_sort: Callback::noop(), + }); + let context = UserSettingsContext { state, open_panel: Callback::noop(), retry_load: Callback::noop() }; + html! { context={context}> + + definition={definition}/> + >} +} + +#[wasm_bindgen_test(async)] +async fn column_rail_is_hidden_while_table_shows_no_content() -> Result<(), JsValue> { + let document = gloo_utils::document(); + let root = document.create_element("div")?; + document.body().ok_or_else(|| JsValue::from_str("missing body"))?.append_child(&root)?; + let handle = yew::Renderer::::with_root(root.clone()).render(); + settle().await; + assert!(root.query_selector(".tp__no_content")?.is_some()); + assert!(root.query_selector(".tp__table-shell__rail")?.is_none()); + assert!(root.query_selector(".tp__table-shell__columns")?.is_none()); + + click(&root, "#toggle-items")?; + settle().await; + let rail = element(&root, ".tp__table-shell__rail")?.dyn_into::()?; + assert!(root.query_selector(".tp__table-shell__columns")?.is_some()); + let header_size = rail.style().get_property_value("--table-header-size")?; + assert!(header_size.trim_end_matches("px").parse::().is_ok_and(|size| size > 0.0)); + + click(&root, "#toggle-items")?; + settle().await; + assert!(root.query_selector(".tp__table-shell__rail")?.is_none()); + handle.destroy(); + root.remove(); + Ok(()) +} diff --git a/frontend/src/app/components/table/mod.rs b/frontend/src/app/components/table/mod.rs index bd6d2a406..d3209ea93 100644 --- a/frontend/src/app/components/table/mod.rs +++ b/frontend/src/app/components/table/mod.rs @@ -83,7 +83,8 @@ pub fn Table(props: &TableProps) -> Html { || () }); } - html! {
+ html! {
{table_markup(&props.definition, &visible, *sort_state, on_header_click, translate.t("LABEL.NO_CONTENT"))}
} } diff --git a/frontend/src/app/components/table/shell.rs b/frontend/src/app/components/table/shell.rs index b6483371c..377346d16 100644 --- a/frontend/src/app/components/table/shell.rs +++ b/frontend/src/app/components/table/shell.rs @@ -83,6 +83,9 @@ pub struct TableShellProps { pub children: Children, #[prop_or(true)] pub supported: bool, + /// Hides the column settings rail, e.g. while the table shows its empty state. + #[prop_or(true)] + pub show_columns: bool, } #[component] @@ -92,8 +95,8 @@ pub fn TableShell(props: &TableShellProps) -> Html { { let content_ref = content_ref.clone(); let strip_ref = strip_ref.clone(); - use_effect_with((), move |()| { - let observers = content_ref.cast::().and_then(|content| { + use_effect_with(props.show_columns, move |show_columns| { + let observers = content_ref.cast::().filter(|_| *show_columns).and_then(|content| { let strip = strip_ref.cast::()?; HeaderObservers::new(&content, &strip) }); @@ -137,12 +140,14 @@ pub fn TableShell(props: &TableShellProps) -> Html { } if ready {
{for props.children.iter()}
} else { }
-
- + if props.show_columns { +
+ + }
} } diff --git a/shared/src/model/config/target/options.rs b/shared/src/model/config/target/options.rs index 8109f4e84..a4b8cb84f 100644 --- a/shared/src/model/config/target/options.rs +++ b/shared/src/model/config/target/options.rs @@ -1,4 +1,7 @@ -use crate::{defaults::is_false, model::ClusterFlags}; +use crate::{ + defaults::{default_as_true, is_false, is_true}, + model::ClusterFlags, +}; #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] @@ -33,17 +36,40 @@ impl ConfigTargetShareLiveStreams { } /// Controls optional canonicalization of EPG data emitted for a target. -#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct EpgOutputOptions { + /// Clears a live channel EPG id that does not resolve to the processed EPG data. + #[serde(default, skip_serializing_if = "is_false")] + pub clear_invalid_ids: bool, #[serde(default, skip_serializing_if = "is_false")] pub lowercase_ids: bool, #[serde(default, skip_serializing_if = "is_false")] pub lowercase_xmltv_display_names: bool, + /// Rewrites a playlist EPG id that matches a guide channel only case-insensitively to the + /// guide's exact `` spelling. + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] + pub adopt_guide_id_case: bool, +} + +impl Default for EpgOutputOptions { + fn default() -> Self { + Self { + clear_invalid_ids: false, + lowercase_ids: false, + lowercase_xmltv_display_names: false, + adopt_guide_id_case: true, + } + } } impl EpgOutputOptions { - pub const fn is_empty(&self) -> bool { !self.lowercase_ids && !self.lowercase_xmltv_display_names } + pub const fn is_empty(&self) -> bool { + !self.clear_invalid_ids + && !self.lowercase_ids + && !self.lowercase_xmltv_display_names + && self.adopt_guide_id_case + } } #[derive(Debug, Copy, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)] @@ -82,9 +108,6 @@ pub struct DeduplicateConfig { pub struct ConfigTargetOptions { #[serde(default, skip_serializing_if = "is_false")] pub ignore_logo: bool, - #[serde(default, skip_serializing_if = "is_false")] - #[serde(alias = "required_epg")] - pub clear_invalid_epg_ids: bool, #[serde( default, deserialize_with = "deserialize_share_live_streams", @@ -104,7 +127,6 @@ pub struct ConfigTargetOptions { impl ConfigTargetOptions { pub fn is_empty(&self) -> bool { !self.ignore_logo - && !self.clear_invalid_epg_ids && self.share_live_streams.is_empty() && !self.remove_duplicates && self.deduplicate.is_none() @@ -116,7 +138,9 @@ impl ConfigTargetOptions { pub const fn lowercase_xmltv_display_names(&self) -> bool { self.epg_output.lowercase_xmltv_display_names } - pub const fn clear_invalid_epg_ids(&self) -> bool { self.clear_invalid_epg_ids } + pub const fn adopt_guide_epg_id_case(&self) -> bool { self.epg_output.adopt_guide_id_case } + + pub const fn clear_invalid_epg_ids(&self) -> bool { self.epg_output.clear_invalid_ids } pub fn share_live_hls_enabled(&self) -> bool { self.share_live_streams.hls } diff --git a/shared/src/model/config/target/tests.rs b/shared/src/model/config/target/tests.rs index a9f50b74e..29aeee1fe 100644 --- a/shared/src/model/config/target/tests.rs +++ b/shared/src/model/config/target/tests.rs @@ -327,16 +327,31 @@ fn target_options_round_trips_partial_share_live_streams() { } #[test] -fn target_options_clear_invalid_epg_ids_roundtrips_and_accepts_legacy_alias() { - let options = serde_saphyr::from_str::("required_epg: true\n") - .expect("legacy required_epg should deserialize"); +fn target_options_epg_output_clear_invalid_ids_roundtrips() { + let yaml = r" +epg_output: + clear_invalid_ids: true +"; + let options = serde_saphyr::from_str::(yaml).expect("clear_invalid_ids should deserialize"); assert!(options.clear_invalid_epg_ids()); assert!(!options.is_empty()); - let serialized = serde_saphyr::to_string(&options).expect("clear_invalid_epg_ids should serialize"); - assert!(serialized.contains("clear_invalid_epg_ids: true")); - assert!(!serialized.contains("required_epg:")); + let serialized = serde_saphyr::to_string(&options).expect("clear_invalid_ids should serialize"); + assert!(serialized.contains("clear_invalid_ids: true"), "got: {serialized}"); + let roundtripped = serde_saphyr::from_str::(&serialized) + .expect("serialized clear_invalid_ids should deserialize"); + assert_eq!(roundtripped, options); +} + +#[test] +fn target_options_reject_removed_top_level_clear_invalid_epg_ids() { + for yaml in ["clear_invalid_epg_ids: true\n", "required_epg: true\n"] { + assert!( + serde_saphyr::from_str::(yaml).is_err(), + "removed top-level key should be rejected: {yaml}" + ); + } } #[test] @@ -346,6 +361,7 @@ fn target_options_default_epg_output_is_disabled_and_omitted() { assert!(!options.lowercase_epg_ids()); assert!(!options.lowercase_xmltv_display_names()); + assert!(options.adopt_guide_epg_id_case()); assert!(options.epg_output.is_empty()); assert!(options.is_empty()); @@ -374,6 +390,26 @@ epg_output: assert_eq!(roundtripped, options); } +#[test] +fn target_options_epg_output_adopt_guide_id_case_can_be_disabled() { + let yaml = r" +epg_output: + adopt_guide_id_case: false +"; + + let options = serde_saphyr::from_str::(yaml).expect("disabled adoption should deserialize"); + + assert!(!options.adopt_guide_epg_id_case()); + assert!(!options.is_empty()); + assert!(ConfigTargetOptions::default().adopt_guide_epg_id_case()); + + let serialized = serde_saphyr::to_string(&options).expect("disabled adoption should serialize"); + assert!(serialized.contains("adopt_guide_id_case: false"), "got: {serialized}"); + let roundtripped = + serde_saphyr::from_str::(&serialized).expect("serialized adoption should deserialize"); + assert_eq!(roundtripped, options); +} + #[test] fn target_options_epg_output_makes_options_nonempty() { let lowercase_ids = ConfigTargetOptions {
{column.label.resolve(|key| translate.t(key))}