From ded3fd14e0b9d05fc940a111055bc8dc0b3579a8 Mon Sep 17 00:00:00 2001 From: zachhide <66033020+zachhide@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:16:40 -0400 Subject: [PATCH] Case-insensitive EPG channel-id matching; stop lowercasing ids in parsers (#783) Make EPG channel-id matching case-insensitive; stop lowercasing ids in parsers --- CHANGELOG.md | 5 + backend/src/processing/parser/m3u.rs | 16 ++- backend/src/processing/parser/xmltv.rs | 144 ++++++++++++++++---- backend/src/processing/processor/epg.rs | 172 ++++++++++++++++++++++-- shared/src/model/playlist.rs | 33 +++++ shared/src/utils/bitset.rs | 18 ++- 6 files changed, 337 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e82e831c..ad448123c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ## ⚠️ Breaking Changes +- **Case-insensitive EPG channel-id matching**: EPG channel-id matching is now case-insensitive (ASCII). Ids are no + longer lowercased when parsed — output preserves the source's original case. Users whose sources provide MixedCase + ids will see MixedCase ids in the M3U/XMLTV output instead of the previously-lowercased form; downstream players may + re-map affected channels once. Supersedes #688 (M3U tvg-id lowercasing removed). + - Removed the `plex` STRM export style. Existing STRM outputs configured with `style: plex` must switch to `kodi`, `emby`, or `jellyfin`; Plex use cases should use the HDHomeRun integration instead. Existing generated TMDB marker paths remain read-compatible, but `style: plex` is no longer accepted in configuration. diff --git a/backend/src/processing/parser/m3u.rs b/backend/src/processing/parser/m3u.rs index 5bfc24aa3..99eeba375 100644 --- a/backend/src/processing/parser/m3u.rs +++ b/backend/src/processing/parser/m3u.rs @@ -358,7 +358,7 @@ fn process_header_internal( } M3uToken::TvgChno => plih.chno = stack[val_off..].parse::().unwrap_or(0), M3uToken::GroupTitle => plih.group = stack[val_off..].intern(), - M3uToken::TvgId => plih.epg_channel_id = if stack.len() == val_off { None } else { Some(stack[val_off..].to_lowercase().intern()) }, + M3uToken::TvgId => plih.epg_channel_id = if stack.len() == val_off { None } else { Some(stack[val_off..].intern()) }, M3uToken::TvgName => plih.name = stack[val_off..].intern(), M3uToken::TvgLogo => plih.logo = stack[val_off..].intern(), M3uToken::TvgLogoSmall => plih.logo_small = stack[val_off..].intern(), @@ -663,6 +663,20 @@ mod test { assert_eq!(pli.epg_channel_id, Some("skycrime.uk".intern())); // tvg-id preserved for EPG } + #[test] + fn test_process_header_tvg_id_preserves_mixed_case() { + // Supersedes #688: the M3U parser no longer lowercases tvg-id. EPG matching is + // now case-insensitive, so the epg_channel_id keeps its original source case and + // the M3U/XMLTV output preserves it. + let input = "test".intern(); + let video_suffixes = Vec::new(); + let url = "http://line.trx-ott.com/live/user/pass/1905905.ts"; + let line = r#"#EXTINF:-1 tvg-id="CNN.us" tvg-name="CNN" group-title="News",CNN"#; + + let pli = process_header(&input, &video_suffixes, line, url.to_string()); + assert_eq!(pli.epg_channel_id, Some("CNN.us".intern())); // original case preserved, not lowercased + } + #[test] fn test_process_header_no_tvg_id_no_provider_id() { let input = "test".intern(); diff --git a/backend/src/processing/parser/xmltv.rs b/backend/src/processing/parser/xmltv.rs index 921805f92..9ef8fcfd4 100644 --- a/backend/src/processing/parser/xmltv.rs +++ b/backend/src/processing/parser/xmltv.rs @@ -3,7 +3,7 @@ use crate::model::{ EPG_TAG_ICON, EPG_TAG_PROGRAMME, EPG_TAG_TV, }; use crate::model::{EpgSmartMatchConfig, PersistedEpgSource}; -use crate::processing::processor::EpgIdCache; +use crate::processing::processor::{with_folded_epg_id, EpgIdCache}; use crate::utils::compressed_file_reader_async::CompressedFileReaderAsync; use crate::utils::{async_file_reader, parse_xmltv_time}; use log::error; @@ -148,7 +148,9 @@ impl TVGuide { if let Some(key) = matched_normalized_name { id_cache.normalized.entry(key).and_modify(|entry| { entry.replace(epg_id.clone()); - id_cache.channel_epg_id.insert(epg_id.clone()); + // Inline fold (mirrors `EpgIdCache::insert_channel_epg_id`); a + // `&mut self` call would break the disjoint field capture here. + with_folded_epg_id(epg_id, |folded| id_cache.channel_epg_id.insert(folded.intern())); matched = true; }); } @@ -333,16 +335,18 @@ impl TVGuide { } Self::prepare_tag(id_cache, &mut tag, smart_match); + // Case-insensitive (ASCII) membership: fold the guide id for the + // lookup only; `tag_epg_id` keeps its original case for output. let add_channel = if smart_match { - id_cache.channel_epg_id.contains(&tag_epg_id) + id_cache.contains_channel_epg_id(&tag_epg_id) || Self::try_fuzzy_matching(id_cache, &tag_epg_id, &tag, fuzzy_matching) } else { - id_cache.channel_epg_id.contains(&tag_epg_id) + id_cache.contains_channel_epg_id(&tag_epg_id) }; if add_channel { - source_processed.insert(Arc::clone(&tag_epg_id)); - id_cache.processed.insert(Arc::clone(&tag_epg_id)); + with_folded_epg_id(&tag_epg_id, |folded| source_processed.insert(folded.intern())); + id_cache.insert_processed_epg_id(&tag_epg_id); accumulator.upsert_channel( epg_source.priority, source_order, @@ -359,7 +363,7 @@ impl TVGuide { } EPG_TAG_PROGRAMME => { if let Some(epg_id) = tag.get_attribute_value(&epg_attrib_channel) { - if source_processed.contains(epg_id) { + if with_folded_epg_id(epg_id, |folded| source_processed.contains(folded)) { if let Some(programme) = Self::extract_programme( &tag, @@ -424,7 +428,7 @@ where let name_raw = String::from_utf8_lossy(binding.as_ref()); let name = name_raw.intern(); let tag_type = get_tag_type(&name); - let attributes = collect_tag_attributes(e, tag_type); + let attributes = collect_tag_attributes(e); let attribs = if attributes.is_empty() { None } else { Some(attributes) }; let tag = XmlTag::new(name, attribs); @@ -523,16 +527,6 @@ impl XmlTagType { pub(crate) fn is_tv(self) -> bool { self == XmlTagType::Tv } - - #[inline] - pub(crate) fn is_channel(self) -> bool { - self == XmlTagType::Channel - } - - #[inline] - pub(crate) fn is_program(self) -> bool { - self == XmlTagType::Programme - } } fn get_tag_type(name: &str) -> XmlTagType { @@ -544,7 +538,7 @@ fn get_tag_type(name: &str) -> XmlTagType { } } -fn collect_tag_attributes(e: &BytesStart, tag_type: XmlTagType) -> HashMap, Arc> { +fn collect_tag_attributes(e: &BytesStart) -> HashMap, Arc> { let attributes = e.attributes().filter_map(Result::ok) .filter_map(|a| { let key_binding = a.key; @@ -553,9 +547,10 @@ fn collect_tag_attributes(e: &BytesStart, tag_type: XmlTagType) -> HashMap + // and are preserved in the output. Some((key, value.intern())) } } else { @@ -621,7 +616,8 @@ impl EpgMergeAccumulator { } fn upsert_channel(&mut self, priority: i16, source_order: usize, logo_override: bool, mut channel: EpgChannel) { - match self.channels.entry(Arc::clone(&channel.id)) { + let channel_key = with_folded_epg_id(&channel.id, |folded| folded.intern()); + match self.channels.entry(channel_key) { std::collections::hash_map::Entry::Occupied(mut entry) => { let acc = entry.get_mut(); acc.needs_programme_merge = true; @@ -665,7 +661,7 @@ impl EpgMergeAccumulator { } fn push_programme(&mut self, priority: i16, source_order: usize, programme: EpgProgramme) { - if let Some(channel) = self.channels.get_mut(programme.get_transient_channel_id()) { + if let Some(channel) = with_folded_epg_id(programme.get_transient_channel_id(), |folded| self.channels.get_mut(folded)) { channel.programmes.push(ProgrammeMergeEntry { priority, source_order, programme }); } else { error!("Channel {} not found in EPG, dangling programme", programme.get_transient_channel_id()); @@ -680,9 +676,9 @@ impl EpgMergeAccumulator { mut channel: EpgChannel, ) { let programmes = std::mem::take(&mut channel.programmes); - let channel_id = Arc::clone(&channel.id); + let channel_key = with_folded_epg_id(&channel.id, |folded| folded.intern()); self.upsert_channel(priority, source_order, logo_override, channel); - if let Some(acc) = self.channels.get_mut(&channel_id) { + if let Some(acc) = self.channels.get_mut(&channel_key) { if !acc.programmes.is_empty() { acc.needs_programme_merge = true; } @@ -1114,7 +1110,7 @@ mod tests { sources: vec![], smart_match: Some(EpgSmartMatchConfig::from(smart_cfg)), })); - id_cache.channel_epg_id.insert("demo.channel".intern()); + id_cache.insert_channel_epg_id("demo.channel"); let merged = tv_guide.filter_merged(&mut id_cache).await.expect("merged epg"); @@ -1124,6 +1120,96 @@ mod tests { }); } + #[test] + fn epg_channel_id_match_is_case_insensitive_and_preserves_guide_case() { + run_async_test(async move { + let dir = tempdir().unwrap(); + let epg_path = dir.path().join("mixed-case.xml"); + + // Guide channel ids are MixedCase and differ in case from the playlist ids. + fs::write( + &epg_path, + r#" + + + Sport Extra + + + CNN + + + Match A + + + Match B + +"#, + ) + .unwrap(); + + let tv_guide = TVGuide::new(vec![PersistedEpgSource { + file_path: epg_path, + priority: 0, + logo_override: false, + }]); + let mut id_cache = EpgIdCache::new(None); + // Playlist epg ids arrive in a *different* case than the guide. Both origins + // (an Xtream source and a mapper-script literal) go through the same + // `insert_channel_epg_id`, which folds the membership key. + id_cache.insert_channel_epg_id("sport.EXTRA1.de"); // e.g. from an Xtream source + id_cache.insert_channel_epg_id("cnn.us"); // e.g. set by a mapper literal @epg_channel_id = "cnn.us" + + let merged = tv_guide.filter_merged(&mut id_cache).await.expect("merged epg"); + + // Both MixedCase guide channels matched despite the case difference. + assert_eq!(merged.children.len(), 2); + let ids: HashSet<&str> = merged.children.iter().map(|c| c.id.as_ref()).collect(); + // Emitted preserves the guide's ORIGINAL case (not folded). + assert!(ids.contains("Sport.Extra1.DE"), "emitted ids: {ids:?}"); + assert!(ids.contains("CNN.US"), "emitted ids: {ids:?}"); + for channel in &merged.children { + assert_eq!(channel.programmes.len(), 1, "channel {} programmes", channel.id); + } + }); + } + + #[test] + fn epg_programme_channel_match_is_case_insensitive_within_source() { + run_async_test(async move { + let dir = tempdir().unwrap(); + let epg_path = dir.path().join("mixed-case-programme.xml"); + + fs::write( + &epg_path, + r#" + + + Demo + + + Case Variant Programme + +"#, + ) + .unwrap(); + + let tv_guide = TVGuide::new(vec![PersistedEpgSource { + file_path: epg_path, + priority: 0, + logo_override: false, + }]); + let mut id_cache = EpgIdCache::new(None); + id_cache.insert_channel_epg_id("demo.channel"); + + let merged = tv_guide.filter_merged(&mut id_cache).await.expect("merged epg"); + + assert_eq!(merged.children.len(), 1); + assert_eq!(merged.children[0].id.as_ref(), "Demo.Channel"); + assert_eq!(merged.children[0].programmes.len(), 1); + assert_eq!(merged.children[0].programmes[0].get_transient_channel_id().as_ref(), "demo.channel"); + }); + } + #[test] fn epg_priority_merge_filter_accepts_later_source_for_already_processed_channel() { run_async_test(async move { @@ -1163,11 +1249,11 @@ mod tests { PersistedEpgSource { file_path: low_path, priority: 10, logo_override: false }, ]); let mut id_cache = EpgIdCache::new(None); - id_cache.channel_epg_id.insert("demo.channel".intern()); + id_cache.insert_channel_epg_id("demo.channel"); let merged = tv_guide.filter_merged(&mut id_cache).await.expect("merged epg"); - assert!(id_cache.processed.contains("demo.channel")); + assert!(id_cache.contains_processed_epg_id("demo.channel")); assert_eq!(merged.children.len(), 1); assert_eq!(merged.children[0].title.as_deref(), Some("High Channel")); assert_eq!(merged.children[0].programmes.len(), 2); @@ -1194,7 +1280,7 @@ mod tests { let tv_guide = TVGuide::new(vec![PersistedEpgSource { file_path, priority: 0, logo_override: false }]); let mut id_cache = EpgIdCache::new(None); - id_cache.channel_epg_id.insert(342u32.intern()); + id_cache.insert_channel_epg_id("342"); //id_cache.collect_epg_id(fp); let channel_ids = HashSet::from([342u32.intern()]); diff --git a/backend/src/processing/processor/epg.rs b/backend/src/processing/processor/epg.rs index 922c870ee..094027348 100644 --- a/backend/src/processing/processor/epg.rs +++ b/backend/src/processing/processor/epg.rs @@ -9,7 +9,37 @@ use shared::model::{EpgSmartMatchConfigDto, PlaylistItem, XtreamCluster}; use std::sync::Arc; use shared::utils::Internable; +const EPG_ID_STACK_FOLD_LEN: usize = 128; + +/// Runs `f` with an ASCII-lowercased EPG id. +/// +/// Non-ASCII characters are left unchanged, matching `eq_ignore_ascii_case` semantics. +pub(crate) fn with_folded_epg_id(id: &str, f: impl FnOnce(&str) -> R) -> R { + if !id.bytes().any(|byte| byte.is_ascii_uppercase()) { + return f(id); + } + + if id.len() <= EPG_ID_STACK_FOLD_LEN { + let mut buf = [0_u8; EPG_ID_STACK_FOLD_LEN]; + for (out, byte) in buf.iter_mut().zip(id.bytes()) { + *out = byte.to_ascii_lowercase(); + } + let folded = std::str::from_utf8(&buf[..id.len()]); + return f(folded.unwrap_or(id)); + } + + let mut folded = String::with_capacity(id.len()); + folded.extend(id.chars().map(|ch| ch.to_ascii_lowercase())); + f(&folded) +} + pub struct EpgIdCache { + /// Membership set of the epg ids collected from playlist channels. + /// Keys are stored ASCII-lowercased so guide-id matching is case-insensitive. + /// This set is used only for membership tests, never to source an emitted/display + /// id, so folding the stored key does not affect output (channels keep their + /// original-case id). Insert via [`Self::insert_channel_epg_id`] and query via + /// [`Self::contains_channel_epg_id`] to keep the fold consistent. pub channel_epg_id: HashSet>, pub normalized: HashMap, Option>>, pub phonetics: HashMap, HashSet>>, @@ -53,6 +83,29 @@ impl EpgIdCache { self.channel_epg_id.is_empty() && self.normalized.is_empty() } + /// Adds an epg id to the case-folded membership set. + /// + /// The key is ASCII-lowercased so a `MixedCase` source id matches a guide + /// `` of a different case. ASCII folding (not Unicode) avoids + /// Turkish-i/locale issues and is faster; channel ids are ASCII. + pub fn insert_channel_epg_id(&mut self, id: &str) { + with_folded_epg_id(id, |folded| self.channel_epg_id.insert(folded.intern())); + } + + /// Case-insensitive (ASCII) membership test against the folded keys stored by + /// [`Self::insert_channel_epg_id`]. + pub fn contains_channel_epg_id(&self, id: &str) -> bool { + with_folded_epg_id(id, |folded| self.channel_epg_id.contains(folded)) + } + + pub fn insert_processed_epg_id(&mut self, id: &str) { + with_folded_epg_id(id, |folded| self.processed.insert(folded.intern())); + } + + pub fn contains_processed_epg_id(&self, id: &str) -> bool { + with_folded_epg_id(id, |folded| self.processed.contains(folded)) + } + /// Normalizes a channel name, computes its phonetic encoding, and stores both in the cache for later EPG matching. /// /// The normalized name is mapped to the provided EPG ID (if any), and the phonetic encoding is added to the phonetics map. @@ -117,7 +170,7 @@ impl EpgIdCache { if let Some(id) = epg_channel_id { if !id.is_empty() { missing_epg_id = false; - self.channel_epg_id.insert(Arc::clone(id)); + self.insert_channel_epg_id(id); } } @@ -141,7 +194,9 @@ impl EpgIdCache { for key in normalized_epg_ids { if let Some(entry) = self.normalized.get_mut(key) { entry.replace(Arc::clone(epg_id)); - self.channel_epg_id.insert(Arc::clone(epg_id)); + // Inline fold (mirrors `insert_channel_epg_id`); a `&mut self` call + // here would conflict with the `entry` borrow of `self.normalized`. + with_folded_epg_id(epg_id, |folded| self.channel_epg_id.insert(folded.intern())); return true; } } @@ -166,17 +221,25 @@ async fn assign_channel_epg(new_epg: &mut Vec, fp: &mut FetchedPlaylist<'_> if let Some(tv_guide) = &fp.epg { if let Some((epg_source, icon_override_channels)) = tv_guide.filter_merged_with_icon_overrides(id_cache).await { let mut icon_assigned = HashSet::new(); - let icon_tags: HashMap<&Arc, &Arc> = epg_source.children + let icon_tags: HashMap, &Arc> = epg_source.children .iter() - .filter_map(|tag| tag.icon.as_ref().filter(|icon| !icon.is_empty()).map(|icon| (&tag.id, icon))) + .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(); + let icon_override_channels = icon_override_channels + .into_iter() + .map(|id| with_folded_epg_id(&id, |folded| folded.intern())) + .collect::>(); let assign_values = |chan: &mut PlaylistItem| { if id_cache.smart_match_enabled { // id_cache.processed contains all epg_ids found in any xmltv source. let not_found_in_epg = match &chan.header.epg_channel_id { None => true, - Some(epg_id) => !id_cache.processed.contains(&**epg_id), + Some(epg_id) => !id_cache.contains_processed_epg_id(epg_id), }; if not_found_in_epg { let try_match = |key: &str| { @@ -196,19 +259,21 @@ async fn assign_channel_epg(new_epg: &mut Vec, fp: &mut FetchedPlaylist<'_> } } if let Some(epg_channel_id) = chan.header.epg_channel_id.as_ref() { - if !icon_assigned.contains(epg_channel_id) && - (icon_override_channels.contains(epg_channel_id) || chan.header.logo.is_empty() || chan.header.logo_small.is_empty()) { - if let Some(icon) = icon_tags.get(epg_channel_id) { - icon_assigned.insert(epg_channel_id.clone()); - if icon_override_channels.contains(epg_channel_id) || chan.header.logo.is_empty() { + with_folded_epg_id(epg_channel_id, |folded_epg_channel_id| { + if !icon_assigned.contains(folded_epg_channel_id) && + (icon_override_channels.contains(folded_epg_channel_id) || chan.header.logo.is_empty() || chan.header.logo_small.is_empty()) { + if let Some(icon) = icon_tags.get(folded_epg_channel_id) { + icon_assigned.insert(folded_epg_channel_id.intern()); + if icon_override_channels.contains(folded_epg_channel_id) || chan.header.logo.is_empty() { trace!("Matched channel {} to epg icon {icon}", chan.header.name); chan.header.logo = Arc::clone(icon); } - if icon_override_channels.contains(epg_channel_id) || chan.header.logo_small.is_empty() { + if icon_override_channels.contains(folded_epg_channel_id) || chan.header.logo_small.is_empty() { chan.header.logo_small = Arc::clone(icon); } } } + }); } }; @@ -254,9 +319,15 @@ pub async fn process_playlist_epg(fp: &mut FetchedPlaylist<'_>, epg: &mut Vec String { @@ -267,6 +338,85 @@ mod tests { .collect() } + #[test] + fn channel_epg_id_membership_is_case_insensitive_ascii() { + let mut cache = super::EpgIdCache::new(None); + // Playlist epg ids stored MixedCase from different origins (Xtream source / + // mapper literal). Both go through the folding insert. + cache.insert_channel_epg_id("CNN.us"); + cache.insert_channel_epg_id("BBC.One.UK"); + + // A guide in any case matches the folded membership key. + assert!(cache.contains_channel_epg_id("cnn.US")); + assert!(cache.contains_channel_epg_id("CNN.US")); + assert!(cache.contains_channel_epg_id("cnn.us")); + assert!(cache.contains_channel_epg_id("bbc.one.uk")); + assert!(!cache.contains_channel_epg_id("unknown.tv")); + } + + #[test] + fn assign_channel_epg_uses_case_insensitive_processed_and_icon_lookup() { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async move { + let dir = tempdir().unwrap(); + let epg_path = dir.path().join("mixed-case-icon.xml"); + + fs::write( + &epg_path, + r#" + + + Demo + + + + Demo Show + +"#, + ) + .unwrap(); + + let mut input = ConfigInput::from(ConfigInputDto::default()); + input.epg = Some(EpgConfig { sources: vec![], smart_match: None }); + let channel = shared::model::PlaylistItem { + header: PlaylistItemHeader { + name: "Demo".intern(), + epg_channel_id: Some("demo.channel".intern()), + logo: "http://old/icon.png".intern(), + logo_small: "".intern(), + xtream_cluster: super::XtreamCluster::Live, + item_type: PlaylistItemType::Live, + ..PlaylistItemHeader::default() + }, + }; + let groups = vec![PlaylistGroup { + id: 1, + title: "Live".intern(), + channels: vec![channel], + xtream_cluster: super::XtreamCluster::Live, + }]; + let tv_guide = TVGuide::new(vec![PersistedEpgSource { + file_path: epg_path, + priority: 0, + logo_override: true, + }]); + let mut playlist = FetchedPlaylist { + input: &input, + source: MemoryPlaylistSource::new(groups).into_source(), + epg: Some(tv_guide), + }; + let mut epg = Vec::new(); + + super::process_playlist_epg(&mut playlist, &mut epg).await; + + let updated = playlist.items_mut().next().unwrap(); + assert_eq!(updated.header.epg_channel_id.as_deref(), Some("demo.channel")); + assert_eq!(updated.header.logo.as_ref(), "http://guide/icon.png"); + assert_eq!(updated.header.logo_small.as_ref(), "http://guide/icon.png"); + assert_eq!(epg[0].children[0].id.as_ref(), "Demo.Channel"); + }); + } + #[test] fn test_phonetic() { let strings: Vec = (0..5_000) diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index d70801d81..b66f375c4 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -1600,6 +1600,39 @@ mod tests { assert!(output.contains(r#"tvg-id="epg_channel_123""#), "M3U output must contain tvg-id from epg_channel_id"); } + #[test] + fn m3u_to_m3u_preserves_mixed_case_tvg_id() { + // EPG matching is case-insensitive, so ids are no longer lowercased when parsed. + // The M3U tvg-id output must preserve the channel's original source case. + 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("CNN.us".intern()), + input_name: "test".intern(), + item_type: PlaylistItemType::Live, + t_stream_url: "".intern(), + t_resource_url: None, + t_catchup_source: None, + t_catchup_mode: None, + source_ordinal: 0, + additional_properties: None, + }; + + let output = item.to_m3u(None, false); + assert!(output.contains(r#"tvg-id="CNN.us""#), "M3U tvg-id must preserve original case, got: {output}"); + } + #[test] fn m3u_to_m3u_emits_tvg_chno_from_chno() { let item = M3uPlaylistItem { diff --git a/shared/src/utils/bitset.rs b/shared/src/utils/bitset.rs index 020c1ce70..f19439411 100644 --- a/shared/src/utils/bitset.rs +++ b/shared/src/utils/bitset.rs @@ -15,16 +15,14 @@ macro_rules! create_bitset { impl [<$enum_name Set>] { pub const VARIANT_COUNT: usize = [$(stringify!($variant)),*].len(); - // COMPILE-TIME CHECK: - // This ensures at compile time that the largest enum variant - // doesn't exceed the storage capacity. Zero runtime cost. - const _CAPACITY_CHECK: () = { - let mut max_val = 0; - $( if ($enum_name::$variant as usize) > max_val { max_val = $enum_name::$variant as usize; } )* - if max_val >= <$storage>::BITS as usize { - panic!("BitSet storage too small for enum variants!"); - } - }; + const _CAPACITY_CHECK: [(); 1] = [(); { + let mut max_val: usize = 0; + $( { + let v = $enum_name::$variant as usize; + if v > max_val { max_val = v; } + } )* + (max_val < <$storage>::BITS as usize) as usize + }]; pub const ALL: Self = if Self::VARIANT_COUNT == <$storage>::BITS as usize { Self(<$storage>::MAX)