// Copyright 2026 Mirakurun contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. use std::collections::{BTreeMap, HashMap, HashSet}; use mirakurun_types::{ Program, ProgramAudio, ProgramGenre, ProgramRelatedItem, ProgramRelatedItemType, ProgramSeries, ProgramVideo, ProgramVideoResolution, ProgramVideoType, program_id, }; use crate::{ arib::decode as decode_arib, filter::SectionAssembler, ts::{Packet, PacketFramer, crc32_mpeg2}, }; const PID_EIT: u16 = 0x0012; #[derive(Debug, Default)] pub struct EitCollector { framer: PacketFramer, assembler: SectionAssembler, programs: HashMap, states: HashMap, sections_seen: HashSet<(u8, u16, u8, u8)>, packet_count: u64, section_count: u64, } impl EitCollector { pub fn push(&mut self, chunk: &[u8]) { let mut packets = Vec::new(); self.framer.push(chunk, |packet| packets.push(*packet)); for packet_bytes in packets { self.packet_count = self.packet_count.saturating_add(1); let Ok(packet) = Packet::new(&packet_bytes) else { continue; }; if packet.pid() != PID_EIT { continue; } for section in self.assembler.push(packet) { if parse_eit_section(§ion, &mut self.programs, &mut self.states) && self.sections_seen.insert(( section[0], u16::from_be_bytes([section[3], section[4]]), (section[5] >> 1) & 0x1f, section[6], )) { self.section_count = self.section_count.saturating_add(1); } } } } #[must_use] pub const fn packet_count(&self) -> u64 { self.packet_count } #[must_use] pub const fn section_count(&self) -> u64 { self.section_count } #[must_use] pub fn into_programs(self) -> Vec { let mut programs = self.programs.into_values().collect::>(); programs.sort_by_key(|program| (program.start_at, program.id)); programs } } #[derive(Debug, Default)] struct VersionRecord(HashMap); impl VersionRecord { fn should_update(&self, table_id: u8, version: u8) -> bool { if self.0.contains_key(&0x4e) && table_id != 0x4e { return false; } if self.0.contains_key(&0x4f) && !matches!(table_id, 0x4e | 0x4f) { return false; } self.0.get(&table_id).copied() != Some(version) } fn mark(&mut self, table_id: u8, version: u8) { self.0.insert(table_id, version); } } #[derive(Debug, Default)] struct ProgramState { initialized: bool, present: bool, following: bool, event: VersionRecord, short: VersionRecord, extended: ExtendedState, component: VersionRecord, content: VersionRecord, audio: HashMap, audios: BTreeMap, series: VersionRecord, group: HashMap, groups: BTreeMap>, } #[derive(Debug, Default)] struct ExtendedState { versions: VersionRecord, parts: BTreeMap>, last_descriptor: Option, done: bool, } #[derive(Debug)] struct ExtendedItem { description: Vec, value: Vec, } fn parse_eit_section( section: &[u8], programs: &mut HashMap, states: &mut HashMap, ) -> bool { if section.len() < 18 || !matches!(section[0], 0x4e..=0x6f) || section[5] & 0x01 == 0 || crc32_mpeg2(section) != 0 { return false; } let table_id = section[0]; let version = (section[5] >> 1) & 0x1f; let is_present_following = matches!(section[0], 0x4e | 0x4f); if is_present_following && section[6] > 1 { return false; } let is_present = is_present_following && section[6] == 0; let is_following = is_present_following && section[6] == 1; let service_id = u16::from_be_bytes([section[3], section[4]]); let network_id = u16::from_be_bytes([section[10], section[11]]); let Some(end) = section.len().checked_sub(4) else { return false; }; let mut offset = 14; while offset + 12 <= end { let event_id = u16::from_be_bytes([section[offset], section[offset + 1]]); let start = §ion[offset + 2..offset + 7]; let duration = §ion[offset + 7..offset + 10]; let descriptor_length = (usize::from(section[offset + 10] & 0x0f) << 8) | usize::from(section[offset + 11]); let Some(descriptor_end) = offset .checked_add(12) .and_then(|value| value.checked_add(descriptor_length)) else { return false; }; if descriptor_end > end { return false; } let id = program_id(network_id, service_id, event_id); let start_at = decode_mjd_time(start); if !programs.contains_key(&id) && start_at.is_none() { offset = descriptor_end; continue; } let program = programs.entry(id).or_insert_with(|| Program { id, event_id, service_id, network_id, start_at: start_at.unwrap_or_default(), duration: decode_bcd_duration(duration).unwrap_or(1), is_free: section[offset + 10] & 0x10 == 0, name: None, description: None, genres: None, video: None, audios: None, series: None, extended: None, related_items: None, }); let state = states.entry(id).or_default(); let update_event = !state.initialized || (!state.present && is_present) || (!state.following && is_following) || state.event.should_update(table_id, version); if update_event { if let Some(start_at) = start_at { program.start_at = start_at; program.duration = decode_bcd_duration(duration).unwrap_or(1); program.is_free = section[offset + 10] & 0x10 == 0; } state.initialized = true; state.present = is_present; state.following = is_following; state.event.mark(table_id, version); } parse_descriptors( §ion[offset + 12..descriptor_end], program, state, table_id, version, ); offset = descriptor_end; } true } fn parse_descriptors( mut descriptors: &[u8], program: &mut Program, state: &mut ProgramState, table_id: u8, version: u8, ) { while descriptors.len() >= 2 { let length = usize::from(descriptors[1]); let Some(end) = 2usize.checked_add(length) else { return; }; if end > descriptors.len() { return; } let body = &descriptors[2..end]; match descriptors[0] { 0x4d if state.short.should_update(table_id, version) => { if let Some((name, description)) = parse_short_event(body) { state.short.mark(table_id, version); program.name = Some(name); program.description = Some(description); } } 0x4e => parse_extended_event(body, program, &mut state.extended, table_id, version), 0x50 if state.component.should_update(table_id, version) => { if let Some(video) = parse_video_component(body) { state.component.mark(table_id, version); program.video = Some(video); } } 0x54 if state.content.should_update(table_id, version) => { if let Some(genres) = parse_content(body) { state.content.mark(table_id, version); program.genres = Some(genres); } } 0xc4 => parse_audio_component(body, program, state, table_id, version), 0xd5 if state.series.should_update(table_id, version) => { if let Some(series) = parse_series(body) { state.series.mark(table_id, version); program.series = Some(series); } } 0xd6 => parse_event_group(body, program, state, table_id, version), _ => {} } descriptors = &descriptors[end..]; } } fn parse_short_event(body: &[u8]) -> Option<(String, String)> { if body.len() < 5 { return None; } let name_length = usize::from(body[3]); let text_length_offset = 4usize.checked_add(name_length)?; if text_length_offset >= body.len() { return None; } let text_length = usize::from(body[text_length_offset]); let text_start = text_length_offset + 1; let text_end = text_start.checked_add(text_length)?; if text_end > body.len() { return None; } Some(( decode_arib(&body[4..text_length_offset]), decode_arib(&body[text_start..text_end]), )) } fn parse_extended_event( body: &[u8], program: &mut Program, state: &mut ExtendedState, table_id: u8, version: u8, ) { if body.len() < 6 { return; } let descriptor_number = body[0] >> 4; let last_descriptor = body[0] & 0x0f; if state.versions.should_update(table_id, version) { state.versions.mark(table_id, version); state.parts.clear(); state.last_descriptor = Some(last_descriptor); state.done = false; } else if state.done { return; } let items_length = usize::from(body[4]); let Some(items_end) = 5usize.checked_add(items_length) else { return; }; if items_end > body.len() { return; } let Some(items) = parse_extended_items(&body[5..items_end]) else { return; }; state.parts.entry(descriptor_number).or_insert(items); let Some(last) = state.last_descriptor else { return; }; if !(0..=last).all(|number| state.parts.contains_key(&number)) { return; } program.extended = Some(build_extended(&state.parts)); state.done = true; } fn parse_extended_items(mut items: &[u8]) -> Option> { let mut parsed = Vec::new(); while !items.is_empty() { let description_length = usize::from(items[0]); let description_end = 1usize.checked_add(description_length)?; if description_end >= items.len() { return None; } let item_length = usize::from(items[description_end]); let item_start = description_end + 1; let item_end = item_start.checked_add(item_length)?; if item_end > items.len() { return None; } parsed.push(ExtendedItem { description: items[1..description_end].to_vec(), value: items[item_start..item_end].to_vec(), }); items = &items[item_end..]; } Some(parsed) } fn build_extended(parts: &BTreeMap>) -> BTreeMap { let mut buffers: BTreeMap>> = BTreeMap::new(); let mut current_key = String::new(); for items in parts.values() { for item in items { let decoded_key = decode_arib(&item.description); if !decoded_key.is_empty() { current_key = decoded_key; } let values = buffers.entry(current_key.clone()).or_default(); if item.description.is_empty() && !values.is_empty() { if let Some(previous) = values.last_mut() { previous.extend_from_slice(&item.value); } } else { values.push(item.value.clone()); } } } buffers .into_iter() .map(|(key, values)| { let value = values .iter() .map(|value| decode_arib(value)) .collect::>() .join("\n\n"); (key, value) }) .collect() } fn parse_video_component(body: &[u8]) -> Option { if body.len() < 2 { return None; } let stream_content = body[0] & 0x0f; let component_type = body[1]; let video_type = match stream_content { 1 => ProgramVideoType::Mpeg2, 5 => ProgramVideoType::H264, 9 => ProgramVideoType::H265, _ => return None, }; let resolution = match component_type { 0x01..=0x04 => ProgramVideoResolution::I480, 0x83 => ProgramVideoResolution::P4320, 0x91..=0x94 => ProgramVideoResolution::P2160, 0xa1..=0xa4 => ProgramVideoResolution::P480, 0xb1..=0xb4 => ProgramVideoResolution::I1080, 0xc1..=0xc4 => ProgramVideoResolution::P720, 0xd1..=0xd4 => ProgramVideoResolution::P240, 0xe1..=0xe4 => ProgramVideoResolution::P1080, _ => return None, }; Some(ProgramVideo { video_type, resolution, stream_content, component_type, }) } fn parse_content(body: &[u8]) -> Option> { let genres = body .chunks_exact(2) .map(|content| ProgramGenre { lv1: content[0] >> 4, lv2: content[0] & 0x0f, un1: content[1] >> 4, un2: content[1] & 0x0f, }) .collect::>(); (!genres.is_empty()).then_some(genres) } fn parse_audio_component( body: &[u8], program: &mut Program, state: &mut ProgramState, table_id: u8, version: u8, ) { if body.len() < 9 { return; } let component_tag = body[2]; let versions = state.audio.entry(component_tag).or_default(); if !versions.should_update(table_id, version) { return; } let multilingual = body[5] & 0x80 != 0; if multilingual && body.len() < 12 { return; } let mut langs = vec![language_code(&body[6..9])]; if multilingual { langs.push(language_code(&body[9..12])); } let sampling_rate = match (body[5] >> 1) & 0x07 { 1 => 16_000, 2 => 22_050, 3 => 24_000, 5 => 32_000, 6 => 44_100, 7 => 48_000, _ => return, }; versions.mark(table_id, version); state.audios.insert( component_tag, ProgramAudio { component_type: body[1], component_tag, is_main: body[5] & 0x40 != 0, sampling_rate, langs, }, ); program.audios = Some(state.audios.values().cloned().collect()); } fn language_code(value: &[u8]) -> String { match value { b"jpn" | b"eng" | b"deu" | b"fra" | b"ita" | b"rus" | b"zho" | b"kor" | b"spa" => { String::from_utf8_lossy(value).into_owned() } _ => "etc".into(), } } fn parse_series(body: &[u8]) -> Option { if body.len() < 8 { return None; } let expires_at = if body[2] & 0x01 != 0 { decode_mjd_date(&body[3..5]).unwrap_or(-1) } else { -1 }; let episodes = (u32::from(body[5]) << 16) | (u32::from(body[6]) << 8) | u32::from(body[7]); Some(ProgramSeries { id: u16::from_be_bytes([body[0], body[1]]), repeat: body[2] >> 4, pattern: (body[2] >> 1) & 0x07, expires_at, episode: u16::try_from(episodes >> 12).ok()?, last_episode: u16::try_from(episodes & 0x0fff).ok()?, name: decode_arib(&body[8..]), }) } fn parse_event_group( body: &[u8], program: &mut Program, state: &mut ProgramState, table_id: u8, version: u8, ) { let Some(&header) = body.first() else { return; }; let group_type = header >> 4; let event_count = usize::from(header & 0x0f); let versions = state.group.entry(group_type).or_default(); if !versions.should_update(table_id, version) { return; } let local_end = 1usize.saturating_add(event_count.saturating_mul(4)); if local_end > body.len() { return; } let item_type = match group_type { 1 => ProgramRelatedItemType::Shared, 2 | 4 => ProgramRelatedItemType::Relay, _ => ProgramRelatedItemType::Movement, }; let mut items = Vec::new(); if matches!(group_type, 4 | 5) { for event in body[local_end..].chunks_exact(8) { items.push(ProgramRelatedItem { item_type, network_id: Some(u16::from_be_bytes([event[0], event[1]])), service_id: u16::from_be_bytes([event[4], event[5]]), event_id: u16::from_be_bytes([event[6], event[7]]), }); } } else { for event in body[1..local_end].chunks_exact(4) { items.push(ProgramRelatedItem { item_type, network_id: None, service_id: u16::from_be_bytes([event[0], event[1]]), event_id: u16::from_be_bytes([event[2], event[3]]), }); } } versions.mark(table_id, version); state.groups.insert(group_type, items); program.related_items = Some(state.groups.values().flatten().cloned().collect()); } fn decode_mjd_time(value: &[u8]) -> Option { if value.len() != 5 || value.iter().all(|byte| *byte == 0xff) { return None; } let mjd = i64::from(u16::from_be_bytes([value[0], value[1]])); let hour = i64::from(decode_bcd(value[2])?); let minute = i64::from(decode_bcd(value[3])?); let second = i64::from(decode_bcd(value[4])?); if hour > 23 || minute > 59 || second > 59 { return None; } let unix_seconds = (mjd - 40_587) .checked_mul(86_400)? .checked_add((hour - 9).checked_mul(3600)?)? .checked_add(minute.checked_mul(60)?)? .checked_add(second)?; u64::try_from(unix_seconds.checked_mul(1000)?).ok() } fn decode_mjd_date(value: &[u8]) -> Option { if value.len() != 2 || value.iter().all(|byte| *byte == 0xff) { return None; } let mjd = i64::from(u16::from_be_bytes([value[0], value[1]])); (mjd - 40_587) .checked_mul(86_400)? .checked_sub(9 * 3_600)? .checked_mul(1000) } fn decode_bcd_duration(value: &[u8]) -> Option { if value.len() != 3 || value.iter().all(|byte| *byte == 0xff) { return None; } let hours = u64::from(decode_bcd(value[0])?); let minutes = u64::from(decode_bcd(value[1])?); let seconds = u64::from(decode_bcd(value[2])?); if minutes > 59 || seconds > 59 { return None; } hours .checked_mul(3600)? .checked_add(minutes.checked_mul(60)?)? .checked_add(seconds)? .checked_mul(1000) } fn decode_bcd(value: u8) -> Option { let high = value >> 4; let low = value & 0x0f; (high <= 9 && low <= 9).then_some(high * 10 + low) } #[cfg(test)] mod tests { use crate::ts::{PACKET_SIZE, crc32_mpeg2}; use mirakurun_types::ProgramRelatedItemType; use super::{EitCollector, VersionRecord, decode_arib, decode_bcd_duration, decode_mjd_time}; #[test] fn decodes_common_arib_character_sets() { assert_eq!(decode_arib(&[0x0e, b'T', b'e', b's', b't']), "Test"); assert_eq!(decode_arib(&[0x19, 0x22]), "あ"); assert_eq!(decode_arib(&[0x1d, 0x22]), "ア"); } #[test] fn converts_arib_time_fields() { assert_eq!(decode_mjd_time(&[0x9e, 0x8b, 0x09, 0x00, 0x00]), Some(0)); assert_eq!(decode_bcd_duration(&[0x01, 0x30, 0x00]), Some(5_400_000)); } #[test] fn collects_program_from_eit_packet() { let short_event = [ 0x4d, 0x0d, b'j', b'p', b'n', 0x05, 0x0e, b'T', b'e', b's', b't', 0x03, 0x0e, b'O', b'K', ]; let mut section = vec![ 0x4e, 0xb0, 0x00, 0x00, 0x65, 0xc1, 0x00, 0x00, 0x00, 0x01, 0x7f, 0xf0, 0x00, 0x4e, 0x00, 0x01, 0x9e, 0x8b, 0x09, 0x00, 0x00, 0x01, 0x00, 0x00, 0x80, 0x0f, ]; section.extend_from_slice(&short_event); let section_length = section.len() - 3 + 4; section[1] = 0xb0 | u8::try_from(section_length >> 8).expect("section length"); section[2] = u8::try_from(section_length & 0xff).expect("section length"); let crc = crc32_mpeg2(§ion); section.extend_from_slice(&crc.to_be_bytes()); let mut packet = [0xff; PACKET_SIZE]; packet[0] = 0x47; packet[1] = 0x40; packet[2] = 0x12; packet[3] = 0x10; packet[4] = 0; packet[5..5 + section.len()].copy_from_slice(§ion); let mut collector = EitCollector::default(); collector.push(&packet); let programs = collector.into_programs(); assert_eq!(programs.len(), 1); assert_eq!(programs[0].name.as_deref(), Some("Test")); assert_eq!(programs[0].description.as_deref(), Some("OK")); assert_eq!(programs[0].duration, 3_600_000); assert!(programs[0].is_free); } #[test] fn parses_audio_series_and_related_program_descriptors() { let mut descriptors = vec![ 0xc4, 0x09, 0x02, 0x03, 0x10, 0x0f, 0xff, 0x4e, b'j', b'p', b'n', ]; let series_name = [0x0e, b'S', b'e', b'r', b'i', b'e', b's']; descriptors.extend_from_slice(&[ 0xd5, u8::try_from(8 + series_name.len()).unwrap_or(0), 0x12, 0x34, 0x14, 0xff, 0xff, 0x00, 0x50, 0x0c, ]); descriptors.extend_from_slice(&series_name); descriptors.extend_from_slice(&[0xd6, 0x05, 0x11, 0x00, 0x65, 0x00, 0x02]); let packet = eit_packet(0x4e, 0, &descriptors); let mut collector = EitCollector::default(); collector.push(&packet); let programs = collector.into_programs(); let program = &programs[0]; let audio = &program.audios.as_ref().expect("audio descriptor")[0]; assert_eq!(audio.component_type, 0x03); assert_eq!(audio.component_tag, 0x10); assert!(audio.is_main); assert_eq!(audio.sampling_rate, 48_000); assert_eq!(audio.langs, ["jpn"]); let series = program.series.as_ref().expect("series descriptor"); assert_eq!(series.id, 0x1234); assert_eq!(series.repeat, 1); assert_eq!(series.pattern, 2); assert_eq!(series.episode, 5); assert_eq!(series.last_episode, 12); assert_eq!(series.name, "Series"); let related = &program.related_items.as_ref().expect("event group")[0]; assert_eq!(related.item_type, ProgramRelatedItemType::Shared); assert_eq!(related.network_id, None); assert_eq!(related.service_id, 101); assert_eq!(related.event_id, 2); } #[test] fn present_following_versions_take_priority_over_schedule_tables() { let mut versions = VersionRecord::default(); assert!(versions.should_update(0x50, 1)); versions.mark(0x50, 1); assert!(versions.should_update(0x4e, 1)); versions.mark(0x4e, 1); assert!(!versions.should_update(0x50, 2)); assert!(versions.should_update(0x4e, 2)); } fn eit_packet(table_id: u8, version: u8, descriptors: &[u8]) -> [u8; PACKET_SIZE] { let descriptor_length = descriptors.len(); let mut section = vec![ table_id, 0xb0, 0x00, 0x00, 0x65, 0xc1 | ((version & 0x1f) << 1), 0x00, 0x00, 0x00, 0x01, 0x7f, 0xf0, 0x00, table_id, 0x00, 0x01, 0x9e, 0x8b, 0x09, 0x00, 0x00, 0x01, 0x00, 0x00, 0x80 | u8::try_from(descriptor_length >> 8).unwrap_or(0), u8::try_from(descriptor_length & 0xff).unwrap_or(0), ]; section.extend_from_slice(descriptors); let section_length = section.len() - 3 + 4; section[1] = 0xb0 | u8::try_from(section_length >> 8).unwrap_or(0); section[2] = u8::try_from(section_length & 0xff).unwrap_or(0); let crc = crc32_mpeg2(§ion); section.extend_from_slice(&crc.to_be_bytes()); let mut packet = [0xff; PACKET_SIZE]; packet[0] = 0x47; packet[1] = 0x40; packet[2] = 0x12; packet[3] = 0x10; packet[4] = 0; packet[5..5 + section.len()].copy_from_slice(§ion); packet } }