From f4c236905714f5f58fd39354afbc98224df16aa0 Mon Sep 17 00:00:00 2001 From: CyberRex <26585194+CyberRex0@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:48:30 +0900 Subject: [PATCH] implement --- crates/mirakurun-core/src/arib.rs | 486 +++++++++++++++ crates/mirakurun-core/src/arib_symbols.rs | 548 +++++++++++++++++ crates/mirakurun-core/src/epg.rs | 697 +++++++++++++++------- crates/mirakurun-core/src/lib.rs | 2 + crates/mirakurun-core/src/logo.rs | 637 ++++++++++++++++++++ crates/mirakurun-core/src/service.rs | 225 +++++-- crates/mirakurun-core/src/tuner.rs | 24 +- crates/mirakurun-rs/src/api.rs | 264 ++++++-- crates/mirakurun-rs/src/jobs.rs | 153 ++++- crates/mirakurun-rs/src/state.rs | 2 + 10 files changed, 2711 insertions(+), 327 deletions(-) create mode 100644 crates/mirakurun-core/src/arib.rs create mode 100644 crates/mirakurun-core/src/arib_symbols.rs create mode 100644 crates/mirakurun-core/src/logo.rs diff --git a/crates/mirakurun-core/src/arib.rs b/crates/mirakurun-core/src/arib.rs new file mode 100644 index 0000000..6da36d5 --- /dev/null +++ b/crates/mirakurun-core/src/arib.rs @@ -0,0 +1,486 @@ +// 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::HashMap; + +use encoding_rs::EUC_JP; + +#[path = "arib_symbols.rs"] +mod symbols; + +const ESC: u8 = 0x1b; +const MACRO: u8 = 0x95; +const MACRO_END: u8 = 0x4f; +const MAX_MACRO_DEPTH: usize = 8; +const DRCS_PRIVATE_USE_BASE: u32 = 0x0f_0000; +const DRCS_0_COUNT: u32 = 94 * 94; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GraphicSet { + Kanji, + Alphanumeric, + Hiragana, + Katakana, + JisX0201Katakana, + Mosaic(u8), + JisPlane1, + JisPlane2, + AdditionalSymbols, + Drcs(u8), + Macro, + Unsupported(u8), +} + +impl GraphicSet { + const fn width(self) -> usize { + match self { + Self::Kanji + | Self::JisPlane1 + | Self::JisPlane2 + | Self::AdditionalSymbols + | Self::Drcs(0) => 2, + Self::Unsupported(width) => width as usize, + _ => 1, + } + } +} + +struct Decoder { + sets: [GraphicSet; 4], + left: usize, + right: usize, + single_shift: Option, + macros: HashMap>, + output: String, +} + +impl Default for Decoder { + fn default() -> Self { + Self { + sets: [ + GraphicSet::Kanji, + GraphicSet::Alphanumeric, + GraphicSet::Hiragana, + GraphicSet::Katakana, + ], + left: 0, + right: 2, + single_shift: None, + macros: HashMap::new(), + output: String::new(), + } + } +} + +impl Decoder { + fn decode(mut self, input: &[u8]) -> String { + self.decode_sequence(input, 0); + self.output + } + + fn decode_sequence(&mut self, input: &[u8], depth: usize) { + if depth > MAX_MACRO_DEPTH { + return; + } + let mut offset = 0; + while offset < input.len() { + let byte = input[offset]; + match byte { + 0x0e => self.left = 1, + 0x0f => self.left = 0, + 0x19 => self.single_shift = Some(2), + 0x1d => self.single_shift = Some(3), + ESC => { + offset += self.consume_escape(&input[offset..]).saturating_sub(1); + } + 0x0d => self.output.push('\n'), + 0x16 => offset = offset.saturating_add(1), + 0x1c => offset = offset.saturating_add(2), + 0x20 | 0xa0 => self.output.push(' '), + MACRO => { + offset += self + .consume_macro_definition(&input[offset..], depth) + .saturating_sub(1); + } + 0x9b => { + offset += consume_csi(&input[offset..]).saturating_sub(1); + } + 0x90 => { + offset += consume_col(&input[offset..]).saturating_sub(1); + } + 0x91..=0x94 | 0x97..=0x98 => offset = offset.saturating_add(1), + 0x9d => { + offset += consume_time(&input[offset..]).saturating_sub(1); + } + 0x21..=0x7e => { + let set = self.single_shift.take().unwrap_or(self.left); + let consumed = + self.decode_graphic(self.sets[set], &input[offset..], false, depth); + offset += consumed.saturating_sub(1); + } + 0xa1..=0xfe => { + let consumed = + self.decode_graphic(self.sets[self.right], &input[offset..], true, depth); + offset += consumed.saturating_sub(1); + } + _ => {} + } + offset += 1; + } + } + + fn decode_graphic( + &mut self, + set: GraphicSet, + input: &[u8], + high_bit: bool, + depth: usize, + ) -> usize { + let width = set.width(); + if input.len() < width { + return input.len().max(1); + } + let first = normalize_graphic(input[0], high_bit); + let second = (width == 2).then(|| normalize_graphic(input[1], high_bit)); + if !(0x21..=0x7e).contains(&first) + || second.is_some_and(|value| !(0x21..=0x7e).contains(&value)) + { + return width; + } + + match set { + GraphicSet::Kanji | GraphicSet::JisPlane1 => { + append_euc_jp( + &[first | 0x80, second.unwrap_or(0) | 0x80], + &mut self.output, + ); + } + GraphicSet::JisPlane2 => { + append_euc_jp( + &[0x8f, first | 0x80, second.unwrap_or(0) | 0x80], + &mut self.output, + ); + } + GraphicSet::Alphanumeric => self.output.push(char::from(first)), + GraphicSet::Hiragana => append_euc_jp(&[0xa4, first | 0x80], &mut self.output), + GraphicSet::Katakana => append_euc_jp(&[0xa5, first | 0x80], &mut self.output), + GraphicSet::JisX0201Katakana => { + let codepoint = 0xff61 + u32::from(first - 0x21); + self.output + .push(char::from_u32(codepoint).unwrap_or('\u{fffd}')); + } + GraphicSet::Mosaic(kind) => self.output.push(mosaic_character(kind, first)), + GraphicSet::AdditionalSymbols => { + self.output.push( + symbols::additional_symbol(first, second.unwrap_or(0)).unwrap_or('\u{fffd}'), + ); + } + GraphicSet::Drcs(set_number) => { + self.output + .push(drcs_character(set_number, first, second).unwrap_or('\u{fffd}')); + } + GraphicSet::Macro => self.invoke_macro(first, depth), + GraphicSet::Unsupported(_) => self.output.push('\u{fffd}'), + } + width + } + + fn invoke_macro(&mut self, code: u8, depth: usize) { + if let Some(body) = self.macros.get(&code).cloned() { + self.decode_sequence(&body, depth + 1); + } else { + self.apply_default_macro(code); + } + } + + fn apply_default_macro(&mut self, code: u8) { + let Some(sets) = default_macro_sets(code) else { + return; + }; + self.sets = sets; + self.left = 0; + self.right = 2; + self.single_shift = None; + } + + fn consume_macro_definition(&mut self, input: &[u8], depth: usize) -> usize { + let Some(&mode) = input.get(1) else { + return 1; + }; + if !matches!(mode, 0x40 | 0x41) { + return 2; + } + let Some(&code) = input.get(2) else { + return 2; + }; + let mut end = 3; + while end + 1 < input.len() { + if input[end] == MACRO && input[end + 1] == MACRO_END { + let body = input[3..end].to_vec(); + self.macros.insert(code, body.clone()); + if mode == 0x41 { + self.decode_sequence(&body, depth + 1); + } + return end + 2; + } + end += 1; + } + input.len() + } + + fn consume_escape(&mut self, input: &[u8]) -> usize { + let Some(&second) = input.get(1) else { + return 1; + }; + match second { + 0x6e => self.left = 2, + 0x6f => self.left = 3, + 0x7c => self.right = 3, + 0x7d => self.right = 2, + 0x7e => self.right = 1, + 0x28..=0x2b => { + let Some(&third) = input.get(2) else { + return 2; + }; + let index = usize::from(second - 0x28); + if third == 0x20 { + let Some(&final_byte) = input.get(3) else { + return 3; + }; + self.sets[index] = drcs_set(final_byte, 1); + return 4; + } + self.sets[index] = + one_byte_graphic_set(third).unwrap_or(GraphicSet::Unsupported(1)); + return 3; + } + 0x24 => { + let Some(&third) = input.get(2) else { + return 2; + }; + if matches!(third, 0x28..=0x2b) { + let index = usize::from(third - 0x28); + let Some(&fourth) = input.get(3) else { + return 3; + }; + if fourth == 0x20 { + let Some(&final_byte) = input.get(4) else { + return 4; + }; + self.sets[index] = drcs_set(final_byte, 2); + return 5; + } + if fourth == 0x28 { + self.sets[index] = GraphicSet::Unsupported(1); + return input.get(4).map_or(4, |_| 5); + } + self.sets[index] = + two_byte_graphic_set(fourth).unwrap_or(GraphicSet::Unsupported(2)); + return 4; + } + self.sets[0] = two_byte_graphic_set(third).unwrap_or(GraphicSet::Unsupported(2)); + return 3; + } + _ => {} + } + 2 + } +} + +#[must_use] +pub(crate) fn decode(input: &[u8]) -> String { + Decoder::default().decode(input) +} + +const fn normalize_graphic(byte: u8, high_bit: bool) -> u8 { + if high_bit { byte & 0x7f } else { byte } +} + +fn append_euc_jp(bytes: &[u8], output: &mut String) { + let (decoded, had_errors) = EUC_JP.decode_without_bom_handling(bytes); + if had_errors { + output.push('\u{fffd}'); + } else { + output.push_str(&decoded); + } +} + +const fn one_byte_graphic_set(code: u8) -> Option { + match code { + 0x30 | 0x37 => Some(GraphicSet::Hiragana), + 0x31 | 0x38 => Some(GraphicSet::Katakana), + 0x32..=0x35 => Some(GraphicSet::Mosaic(code - 0x32)), + 0x36 | 0x4a => Some(GraphicSet::Alphanumeric), + 0x49 => Some(GraphicSet::JisX0201Katakana), + 0x70 => Some(GraphicSet::Macro), + _ => None, + } +} + +const fn two_byte_graphic_set(code: u8) -> Option { + match code { + 0x39 => Some(GraphicSet::JisPlane1), + 0x3a => Some(GraphicSet::JisPlane2), + 0x3b => Some(GraphicSet::AdditionalSymbols), + 0x42 => Some(GraphicSet::Kanji), + _ => None, + } +} + +const fn drcs_set(final_byte: u8, width: u8) -> GraphicSet { + if final_byte >= 0x40 && final_byte <= 0x4f { + GraphicSet::Drcs(final_byte - 0x40) + } else { + GraphicSet::Unsupported(width) + } +} + +fn drcs_character(set: u8, first: u8, second: Option) -> Option { + let index = if set == 0 { + u32::from(first.checked_sub(0x21)?) * 94 + u32::from(second?.checked_sub(0x21)?) + } else { + DRCS_0_COUNT + u32::from(set.checked_sub(1)?) * 94 + u32::from(first.checked_sub(0x21)?) + }; + char::from_u32(DRCS_PRIVATE_USE_BASE + index) +} + +const fn mosaic_character(kind: u8, code: u8) -> char { + const MOSAIC: [char; 4] = ['▦', '▥', '▧', '▨']; + let index = ((kind as usize) + (code as usize & 0x03)) % MOSAIC.len(); + MOSAIC[index] +} + +const fn default_macro_sets(code: u8) -> Option<[GraphicSet; 4]> { + let macro_set = GraphicSet::Macro; + match code { + 0x60 => Some([ + GraphicSet::Kanji, + GraphicSet::Alphanumeric, + GraphicSet::Hiragana, + macro_set, + ]), + 0x61 => Some([ + GraphicSet::Kanji, + GraphicSet::Katakana, + GraphicSet::Hiragana, + macro_set, + ]), + 0x62 => Some([ + GraphicSet::Kanji, + GraphicSet::Drcs(1), + GraphicSet::Hiragana, + macro_set, + ]), + 0x63 => Some([ + GraphicSet::Mosaic(0), + GraphicSet::Mosaic(2), + GraphicSet::Mosaic(3), + macro_set, + ]), + 0x64 => Some([ + GraphicSet::Mosaic(0), + GraphicSet::Mosaic(1), + GraphicSet::Mosaic(3), + macro_set, + ]), + 0x65 => Some([ + GraphicSet::Mosaic(0), + GraphicSet::Drcs(1), + GraphicSet::Mosaic(3), + macro_set, + ]), + 0x66..=0x6a => { + let first = 1 + (code - 0x66) * 3; + Some([ + GraphicSet::Drcs(first), + GraphicSet::Drcs(first + 1), + GraphicSet::Drcs(first + 2), + macro_set, + ]) + } + 0x6b..=0x6d => Some([ + GraphicSet::Kanji, + GraphicSet::Drcs(2 + code - 0x6b), + GraphicSet::Hiragana, + macro_set, + ]), + 0x6e => Some([ + GraphicSet::Katakana, + GraphicSet::Hiragana, + GraphicSet::Alphanumeric, + macro_set, + ]), + 0x6f => Some([ + GraphicSet::Alphanumeric, + GraphicSet::Mosaic(0), + GraphicSet::Drcs(1), + macro_set, + ]), + _ => None, + } +} + +fn consume_csi(input: &[u8]) -> usize { + input + .iter() + .enumerate() + .skip(1) + .find_map(|(index, byte)| (0x40..=0x7e).contains(byte).then_some(index + 1)) + .unwrap_or(input.len()) +} + +fn consume_col(input: &[u8]) -> usize { + match input.get(1) { + Some(0x20) => input.len().min(3), + Some(_) => input.len().min(2), + None => 1, + } +} + +fn consume_time(input: &[u8]) -> usize { + match input.get(1) { + Some(0x20 | 0x28) => input.len().min(3), + Some(_) => consume_csi(input), + None => 1, + } +} + +#[cfg(test)] +mod tests { + use super::{DRCS_PRIVATE_USE_BASE, decode}; + + #[test] + fn decodes_common_character_sets() { + assert_eq!(decode(&[0x0e, b'T', b'e', b's', b't']), "Test"); + assert_eq!(decode(&[0x19, 0x22]), "あ"); + assert_eq!(decode(&[0x1d, 0x22]), "ア"); + } + + #[test] + fn decodes_additional_symbols_and_kanji() { + assert_eq!(decode(&[0x1b, 0x24, 0x3b, 0x7a, 0x30]), "🅿"); + assert_eq!(decode(&[0x1b, 0x24, 0x3b, 0x75, 0x21]), "㐂"); + } + + #[test] + fn preserves_drcs_identity_in_private_use_plane() { + let expected = char::from_u32(DRCS_PRIVATE_USE_BASE).map(String::from); + assert_eq!( + decode(&[0x1b, 0x24, 0x28, 0x20, 0x40, 0x21, 0x21]), + expected.unwrap_or_default() + ); + } + + #[test] + fn applies_default_and_dynamically_defined_macros() { + assert_eq!(decode(&[0x1b, 0x2b, 0x70, 0x1d, 0x61, 0x0e, 0x22]), "ア"); + assert_eq!( + decode(&[ + 0x1b, 0x2b, 0x70, 0x95, 0x40, 0x21, 0x0e, 0x95, 0x4f, 0x1d, 0x21, b'A', + ]), + "A" + ); + } +} diff --git a/crates/mirakurun-core/src/arib_symbols.rs b/crates/mirakurun-core/src/arib_symbols.rs new file mode 100644 index 0000000..a82a4a3 --- /dev/null +++ b/crates/mirakurun-core/src/arib_symbols.rs @@ -0,0 +1,548 @@ +// 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. + +// ARIB STD-B24 additional Kanji and symbol mappings. +#[allow( + clippy::match_same_arms, + clippy::too_many_lines, + clippy::unreadable_literal +)] +pub(super) fn additional_symbol(first: u8, second: u8) -> Option { + let code = (u16::from(first) << 8) | u16::from(second); + match code { + 0x7A21 => char::from_u32(0x000026CC), + 0x7A22 => char::from_u32(0x000026CD), + 0x7A23 => char::from_u32(0x00002757), + 0x7A24 => char::from_u32(0x000026CF), + 0x7A25 => char::from_u32(0x000026D0), + 0x7A26 => char::from_u32(0x000026D1), + 0x7A28 => char::from_u32(0x000026D2), + 0x7A29 => char::from_u32(0x000026D5), + 0x7A2A => char::from_u32(0x000026D3), + 0x7A2B => char::from_u32(0x000026D4), + 0x7A30 => char::from_u32(0x0001F17F), + 0x7A31 => char::from_u32(0x0001F18A), + 0x7A34 => char::from_u32(0x000026D6), + 0x7A35 => char::from_u32(0x000026D7), + 0x7A36 => char::from_u32(0x000026D8), + 0x7A37 => char::from_u32(0x000026D9), + 0x7A38 => char::from_u32(0x000026DA), + 0x7A39 => char::from_u32(0x000026DB), + 0x7A3A => char::from_u32(0x000026DC), + 0x7A3B => char::from_u32(0x000026DD), + 0x7A3C => char::from_u32(0x000026DE), + 0x7A3D => char::from_u32(0x000026DF), + 0x7A3E => char::from_u32(0x000026E0), + 0x7A3F => char::from_u32(0x000026E1), + 0x7A40 => char::from_u32(0x00002B55), + 0x7A41 => char::from_u32(0x00003248), + 0x7A42 => char::from_u32(0x00003249), + 0x7A43 => char::from_u32(0x0000324A), + 0x7A44 => char::from_u32(0x0000324B), + 0x7A45 => char::from_u32(0x0000324C), + 0x7A46 => char::from_u32(0x0000324D), + 0x7A47 => char::from_u32(0x0000324E), + 0x7A48 => char::from_u32(0x0000324F), + 0x7A4D => char::from_u32(0x00002491), + 0x7A4E => char::from_u32(0x00002492), + 0x7A4F => char::from_u32(0x00002493), + 0x7A50 => char::from_u32(0x0001F14A), + 0x7A51 => char::from_u32(0x0001F14C), + 0x7A52 => char::from_u32(0x0001F13F), + 0x7A53 => char::from_u32(0x0001F146), + 0x7A54 => char::from_u32(0x0001F14B), + 0x7A55 => char::from_u32(0x0001F210), + 0x7A56 => char::from_u32(0x0001F211), + 0x7A57 => char::from_u32(0x0001F212), + 0x7A58 => char::from_u32(0x0001F213), + 0x7A59 => char::from_u32(0x0001F142), + 0x7A5A => char::from_u32(0x0001F214), + 0x7A5B => char::from_u32(0x0001F215), + 0x7A5C => char::from_u32(0x0001F216), + 0x7A5D => char::from_u32(0x0001F14D), + 0x7A5E => char::from_u32(0x0001F131), + 0x7A5F => char::from_u32(0x0001F13D), + 0x7A60 => char::from_u32(0x00002B1B), + 0x7A61 => char::from_u32(0x00002B24), + 0x7A62 => char::from_u32(0x0001F217), + 0x7A63 => char::from_u32(0x0001F218), + 0x7A64 => char::from_u32(0x0001F219), + 0x7A65 => char::from_u32(0x0001F21A), + 0x7A66 => char::from_u32(0x0001F21B), + 0x7A67 => char::from_u32(0x000026BF), + 0x7A68 => char::from_u32(0x0001F21C), + 0x7A69 => char::from_u32(0x0001F21D), + 0x7A6A => char::from_u32(0x0001F21E), + 0x7A6B => char::from_u32(0x0001F21F), + 0x7A6C => char::from_u32(0x0001F220), + 0x7A6D => char::from_u32(0x0001F221), + 0x7A6E => char::from_u32(0x0001F222), + 0x7A6F => char::from_u32(0x0001F223), + 0x7A70 => char::from_u32(0x0001F224), + 0x7A71 => char::from_u32(0x0001F225), + 0x7A72 => char::from_u32(0x0001F14E), + 0x7A73 => char::from_u32(0x00003299), + 0x7A74 => char::from_u32(0x0001F200), + 0x7B21 => char::from_u32(0x000026E3), + 0x7B22 => char::from_u32(0x00002B56), + 0x7B23 => char::from_u32(0x00002B57), + 0x7B24 => char::from_u32(0x00002B58), + 0x7B25 => char::from_u32(0x00002B59), + 0x7B26 => char::from_u32(0x00002613), + 0x7B27 => char::from_u32(0x0000328B), + 0x7B28 => char::from_u32(0x00003012), + 0x7B29 => char::from_u32(0x000026E8), + 0x7B2A => char::from_u32(0x00003246), + 0x7B2B => char::from_u32(0x00003245), + 0x7B2C => char::from_u32(0x000026E9), + 0x7B2D => char::from_u32(0x00000FD6), + 0x7B2E => char::from_u32(0x000026EA), + 0x7B2F => char::from_u32(0x000026EB), + 0x7B30 => char::from_u32(0x000026EC), + 0x7B31 => char::from_u32(0x00002668), + 0x7B32 => char::from_u32(0x000026ED), + 0x7B33 => char::from_u32(0x000026EE), + 0x7B34 => char::from_u32(0x000026EF), + 0x7B35 => char::from_u32(0x00002693), + 0x7B36 => char::from_u32(0x00002708), + 0x7B37 => char::from_u32(0x000026F0), + 0x7B38 => char::from_u32(0x000026F1), + 0x7B39 => char::from_u32(0x000026F2), + 0x7B3A => char::from_u32(0x000026F3), + 0x7B3B => char::from_u32(0x000026F4), + 0x7B3C => char::from_u32(0x000026F5), + 0x7B3D => char::from_u32(0x0001F157), + 0x7B3E => char::from_u32(0x000024B9), + 0x7B3F => char::from_u32(0x000024C8), + 0x7B40 => char::from_u32(0x000026F6), + 0x7B41 => char::from_u32(0x0001F15F), + 0x7B42 => char::from_u32(0x0001F18B), + 0x7B43 => char::from_u32(0x0001F18D), + 0x7B44 => char::from_u32(0x0001F18C), + 0x7B45 => char::from_u32(0x0001F179), + 0x7B46 => char::from_u32(0x000026F7), + 0x7B47 => char::from_u32(0x000026F8), + 0x7B48 => char::from_u32(0x000026F9), + 0x7B49 => char::from_u32(0x000026FA), + 0x7B4A => char::from_u32(0x0001F17B), + 0x7B4B => char::from_u32(0x0000260E), + 0x7B4C => char::from_u32(0x000026FB), + 0x7B4D => char::from_u32(0x000026FC), + 0x7B4E => char::from_u32(0x000026FD), + 0x7B4F => char::from_u32(0x000026FE), + 0x7B50 => char::from_u32(0x0001F17C), + 0x7B51 => char::from_u32(0x000026FF), + 0x7C21 => char::from_u32(0x000027A1), + 0x7C22 => char::from_u32(0x00002B05), + 0x7C23 => char::from_u32(0x00002B06), + 0x7C24 => char::from_u32(0x00002B07), + 0x7C25 => char::from_u32(0x00002B2F), + 0x7C26 => char::from_u32(0x00002B2E), + 0x7C27 => char::from_u32(0x00005E74), + 0x7C28 => char::from_u32(0x00006708), + 0x7C29 => char::from_u32(0x000065E5), + 0x7C2A => char::from_u32(0x00005186), + 0x7C2B => char::from_u32(0x000033A1), + 0x7C2C => char::from_u32(0x000033A5), + 0x7C2D => char::from_u32(0x0000339D), + 0x7C2E => char::from_u32(0x000033A0), + 0x7C2F => char::from_u32(0x000033A4), + 0x7C30 => char::from_u32(0x0001F100), + 0x7C31 => char::from_u32(0x00002488), + 0x7C32 => char::from_u32(0x00002489), + 0x7C33 => char::from_u32(0x0000248A), + 0x7C34 => char::from_u32(0x0000248B), + 0x7C35 => char::from_u32(0x0000248C), + 0x7C36 => char::from_u32(0x0000248D), + 0x7C37 => char::from_u32(0x0000248E), + 0x7C38 => char::from_u32(0x0000248F), + 0x7C39 => char::from_u32(0x00002490), + 0x7C3A => char::from_u32(0x0000E290), + 0x7C3B => char::from_u32(0x0000E291), + 0x7C3C => char::from_u32(0x0000E292), + 0x7C3D => char::from_u32(0x0000E293), + 0x7C3E => char::from_u32(0x0000E294), + 0x7C3F => char::from_u32(0x0000E295), + 0x7C40 => char::from_u32(0x0001F101), + 0x7C41 => char::from_u32(0x0001F102), + 0x7C42 => char::from_u32(0x0001F103), + 0x7C43 => char::from_u32(0x0001F104), + 0x7C44 => char::from_u32(0x0001F105), + 0x7C45 => char::from_u32(0x0001F106), + 0x7C46 => char::from_u32(0x0001F107), + 0x7C47 => char::from_u32(0x0001F108), + 0x7C48 => char::from_u32(0x0001F109), + 0x7C49 => char::from_u32(0x0001F10A), + 0x7C4A => char::from_u32(0x00003233), + 0x7C4B => char::from_u32(0x00003236), + 0x7C4C => char::from_u32(0x00003232), + 0x7C4D => char::from_u32(0x00003231), + 0x7C4E => char::from_u32(0x00003239), + 0x7C4F => char::from_u32(0x00003244), + 0x7C50 => char::from_u32(0x000025B6), + 0x7C51 => char::from_u32(0x000025C0), + 0x7C52 => char::from_u32(0x00003016), + 0x7C53 => char::from_u32(0x00003017), + 0x7C54 => char::from_u32(0x000027D0), + 0x7C55 => char::from_u32(0x000000B2), + 0x7C56 => char::from_u32(0x000000B3), + 0x7C57 => char::from_u32(0x0001F12D), + 0x7C58 => char::from_u32(0x0000E2A5), + 0x7C59 => char::from_u32(0x0000E2A6), + 0x7C5A => char::from_u32(0x0000E2A7), + 0x7C5B => char::from_u32(0x0000E2A8), + 0x7C5C => char::from_u32(0x0000E2A9), + 0x7C5D => char::from_u32(0x0000E2AA), + 0x7C5E => char::from_u32(0x0000E2AB), + 0x7C5F => char::from_u32(0x0000E2AC), + 0x7C60 => char::from_u32(0x0000E2AD), + 0x7C61 => char::from_u32(0x0000E2AE), + 0x7C62 => char::from_u32(0x0000E2AF), + 0x7C63 => char::from_u32(0x0000E2B0), + 0x7C64 => char::from_u32(0x0000E2B1), + 0x7C65 => char::from_u32(0x0000E2B2), + 0x7C66 => char::from_u32(0x0000E2B3), + 0x7C67 => char::from_u32(0x0000E2B4), + 0x7C68 => char::from_u32(0x0000E2B5), + 0x7C69 => char::from_u32(0x0000E2B6), + 0x7C6A => char::from_u32(0x0000E2B7), + 0x7C6B => char::from_u32(0x0000E2B8), + 0x7C6C => char::from_u32(0x0000E2B9), + 0x7C6D => char::from_u32(0x0000E2BA), + 0x7C6E => char::from_u32(0x0000E2BB), + 0x7C6F => char::from_u32(0x0000E2BC), + 0x7C70 => char::from_u32(0x0000E2BD), + 0x7C71 => char::from_u32(0x0000E2BE), + 0x7C72 => char::from_u32(0x0000E2BF), + 0x7C73 => char::from_u32(0x0000E2C0), + 0x7C74 => char::from_u32(0x0000E2C1), + 0x7C75 => char::from_u32(0x0000E2C2), + 0x7C76 => char::from_u32(0x0001F12C), + 0x7C77 => char::from_u32(0x0001F12B), + 0x7C78 => char::from_u32(0x00003247), + 0x7C79 => char::from_u32(0x0001F190), + 0x7C7A => char::from_u32(0x0001F226), + 0x7C7B => char::from_u32(0x0000213B), + 0x7D21 => char::from_u32(0x0000322A), + 0x7D22 => char::from_u32(0x0000322B), + 0x7D23 => char::from_u32(0x0000322C), + 0x7D24 => char::from_u32(0x0000322D), + 0x7D25 => char::from_u32(0x0000322E), + 0x7D26 => char::from_u32(0x0000322F), + 0x7D27 => char::from_u32(0x00003230), + 0x7D28 => char::from_u32(0x00003237), + 0x7D29 => char::from_u32(0x0000337E), + 0x7D2A => char::from_u32(0x0000337D), + 0x7D2B => char::from_u32(0x0000337C), + 0x7D2C => char::from_u32(0x0000337B), + 0x7D2D => char::from_u32(0x00002116), + 0x7D2E => char::from_u32(0x00002121), + 0x7D2F => char::from_u32(0x00003036), + 0x7D30 => char::from_u32(0x000026BE), + 0x7D31 => char::from_u32(0x0001F240), + 0x7D32 => char::from_u32(0x0001F241), + 0x7D33 => char::from_u32(0x0001F242), + 0x7D34 => char::from_u32(0x0001F243), + 0x7D35 => char::from_u32(0x0001F244), + 0x7D36 => char::from_u32(0x0001F245), + 0x7D37 => char::from_u32(0x0001F246), + 0x7D38 => char::from_u32(0x0001F247), + 0x7D39 => char::from_u32(0x0001F248), + 0x7D3A => char::from_u32(0x0001F12A), + 0x7D3B => char::from_u32(0x0001F227), + 0x7D3C => char::from_u32(0x0001F228), + 0x7D3D => char::from_u32(0x0001F229), + 0x7D3E => char::from_u32(0x0001F214), + 0x7D3F => char::from_u32(0x0001F22A), + 0x7D40 => char::from_u32(0x0001F22B), + 0x7D41 => char::from_u32(0x0001F22C), + 0x7D42 => char::from_u32(0x0001F22D), + 0x7D43 => char::from_u32(0x0001F22E), + 0x7D44 => char::from_u32(0x0001F22F), + 0x7D45 => char::from_u32(0x0001F230), + 0x7D46 => char::from_u32(0x0001F231), + 0x7D47 => char::from_u32(0x00002113), + 0x7D48 => char::from_u32(0x0000338F), + 0x7D49 => char::from_u32(0x00003390), + 0x7D4A => char::from_u32(0x000033CA), + 0x7D4B => char::from_u32(0x0000339E), + 0x7D4C => char::from_u32(0x000033A2), + 0x7D4D => char::from_u32(0x00003371), + 0x7D50 => char::from_u32(0x000000BD), + 0x7D51 => char::from_u32(0x00002189), + 0x7D52 => char::from_u32(0x00002153), + 0x7D53 => char::from_u32(0x00002154), + 0x7D54 => char::from_u32(0x000000BC), + 0x7D55 => char::from_u32(0x000000BE), + 0x7D56 => char::from_u32(0x00002155), + 0x7D57 => char::from_u32(0x00002156), + 0x7D58 => char::from_u32(0x00002157), + 0x7D59 => char::from_u32(0x00002158), + 0x7D5A => char::from_u32(0x00002159), + 0x7D5B => char::from_u32(0x0000215A), + 0x7D5C => char::from_u32(0x00002150), + 0x7D5D => char::from_u32(0x0000215B), + 0x7D5E => char::from_u32(0x00002151), + 0x7D5F => char::from_u32(0x00002152), + 0x7D60 => char::from_u32(0x00002600), + 0x7D61 => char::from_u32(0x00002601), + 0x7D62 => char::from_u32(0x00002602), + 0x7D63 => char::from_u32(0x000026C4), + 0x7D64 => char::from_u32(0x00002616), + 0x7D65 => char::from_u32(0x00002617), + 0x7D66 => char::from_u32(0x000026C9), + 0x7D67 => char::from_u32(0x000026CA), + 0x7D68 => char::from_u32(0x00002666), + 0x7D69 => char::from_u32(0x00002665), + 0x7D6A => char::from_u32(0x00002663), + 0x7D6B => char::from_u32(0x00002660), + 0x7D6C => char::from_u32(0x000026CB), + 0x7D6D => char::from_u32(0x00002A00), + 0x7D6E => char::from_u32(0x0000203C), + 0x7D6F => char::from_u32(0x00002049), + 0x7D70 => char::from_u32(0x000026C5), + 0x7D71 => char::from_u32(0x00002614), + 0x7D72 => char::from_u32(0x000026C6), + 0x7D73 => char::from_u32(0x00002603), + 0x7D74 => char::from_u32(0x000026C7), + 0x7D75 => char::from_u32(0x000026A1), + 0x7D76 => char::from_u32(0x000026C8), + 0x7D78 => char::from_u32(0x0000269E), + 0x7D79 => char::from_u32(0x0000269F), + 0x7D7A => char::from_u32(0x0000266C), + 0x7D7B => char::from_u32(0x0000260E), + 0x7E21 => char::from_u32(0x00002160), + 0x7E22 => char::from_u32(0x00002161), + 0x7E23 => char::from_u32(0x00002162), + 0x7E24 => char::from_u32(0x00002163), + 0x7E25 => char::from_u32(0x00002164), + 0x7E26 => char::from_u32(0x00002165), + 0x7E27 => char::from_u32(0x00002166), + 0x7E28 => char::from_u32(0x00002167), + 0x7E29 => char::from_u32(0x00002168), + 0x7E2A => char::from_u32(0x00002169), + 0x7E2B => char::from_u32(0x0000216A), + 0x7E2C => char::from_u32(0x0000216B), + 0x7E2D => char::from_u32(0x00002470), + 0x7E2E => char::from_u32(0x00002471), + 0x7E2F => char::from_u32(0x00002472), + 0x7E30 => char::from_u32(0x00002473), + 0x7E31 => char::from_u32(0x00002474), + 0x7E32 => char::from_u32(0x00002475), + 0x7E33 => char::from_u32(0x00002476), + 0x7E34 => char::from_u32(0x00002477), + 0x7E35 => char::from_u32(0x00002478), + 0x7E36 => char::from_u32(0x00002479), + 0x7E37 => char::from_u32(0x0000247A), + 0x7E38 => char::from_u32(0x0000247B), + 0x7E39 => char::from_u32(0x0000247C), + 0x7E3A => char::from_u32(0x0000247D), + 0x7E3B => char::from_u32(0x0000247E), + 0x7E3C => char::from_u32(0x0000247F), + 0x7E3D => char::from_u32(0x00003251), + 0x7E3E => char::from_u32(0x00003252), + 0x7E3F => char::from_u32(0x00003253), + 0x7E40 => char::from_u32(0x00003254), + 0x7E41 => char::from_u32(0x0001F110), + 0x7E42 => char::from_u32(0x0001F111), + 0x7E43 => char::from_u32(0x0001F112), + 0x7E44 => char::from_u32(0x0001F113), + 0x7E45 => char::from_u32(0x0001F114), + 0x7E46 => char::from_u32(0x0001F115), + 0x7E47 => char::from_u32(0x0001F116), + 0x7E48 => char::from_u32(0x0001F117), + 0x7E49 => char::from_u32(0x0001F118), + 0x7E4A => char::from_u32(0x0001F119), + 0x7E4B => char::from_u32(0x0001F11A), + 0x7E4C => char::from_u32(0x0001F11B), + 0x7E4D => char::from_u32(0x0001F11C), + 0x7E4E => char::from_u32(0x0001F11D), + 0x7E4F => char::from_u32(0x0001F11E), + 0x7E50 => char::from_u32(0x0001F11F), + 0x7E51 => char::from_u32(0x0001F120), + 0x7E52 => char::from_u32(0x0001F121), + 0x7E53 => char::from_u32(0x0001F122), + 0x7E54 => char::from_u32(0x0001F123), + 0x7E55 => char::from_u32(0x0001F124), + 0x7E56 => char::from_u32(0x0001F125), + 0x7E57 => char::from_u32(0x0001F126), + 0x7E58 => char::from_u32(0x0001F127), + 0x7E59 => char::from_u32(0x0001F128), + 0x7E5A => char::from_u32(0x0001F129), + 0x7E5B => char::from_u32(0x00003255), + 0x7E5C => char::from_u32(0x00003256), + 0x7E5D => char::from_u32(0x00003257), + 0x7E5E => char::from_u32(0x00003258), + 0x7E5F => char::from_u32(0x00003259), + 0x7E60 => char::from_u32(0x0000325A), + 0x7E61 => char::from_u32(0x00002460), + 0x7E62 => char::from_u32(0x00002461), + 0x7E63 => char::from_u32(0x00002462), + 0x7E64 => char::from_u32(0x00002463), + 0x7E65 => char::from_u32(0x00002464), + 0x7E66 => char::from_u32(0x00002465), + 0x7E67 => char::from_u32(0x00002466), + 0x7E68 => char::from_u32(0x00002467), + 0x7E69 => char::from_u32(0x00002468), + 0x7E6A => char::from_u32(0x00002469), + 0x7E6B => char::from_u32(0x0000246A), + 0x7E6C => char::from_u32(0x0000246B), + 0x7E6D => char::from_u32(0x0000246C), + 0x7E6E => char::from_u32(0x0000246D), + 0x7E6F => char::from_u32(0x0000246E), + 0x7E70 => char::from_u32(0x0000246F), + 0x7E71 => char::from_u32(0x00002776), + 0x7E72 => char::from_u32(0x00002777), + 0x7E73 => char::from_u32(0x00002778), + 0x7E74 => char::from_u32(0x00002779), + 0x7E75 => char::from_u32(0x0000277A), + 0x7E76 => char::from_u32(0x0000277B), + 0x7E77 => char::from_u32(0x0000277C), + 0x7E78 => char::from_u32(0x0000277D), + 0x7E79 => char::from_u32(0x0000277E), + 0x7E7A => char::from_u32(0x0000277F), + 0x7E7B => char::from_u32(0x000024EB), + 0x7E7C => char::from_u32(0x000024EC), + 0x7E7D => char::from_u32(0x0000325B), + 0x7521 => char::from_u32(0x00003402), + 0x7522 => char::from_u32(0x00020158), + 0x7523 => char::from_u32(0x00004EFD), + 0x7524 => char::from_u32(0x00004EFF), + 0x7525 => char::from_u32(0x00004F9A), + 0x7526 => char::from_u32(0x00004FC9), + 0x7527 => char::from_u32(0x0000509C), + 0x7528 => char::from_u32(0x0000511E), + 0x7529 => char::from_u32(0x000051BC), + 0x752A => char::from_u32(0x0000351F), + 0x752B => char::from_u32(0x00005307), + 0x752C => char::from_u32(0x00005361), + 0x752D => char::from_u32(0x0000536C), + 0x752E => char::from_u32(0x00008A79), + 0x752F => char::from_u32(0x00020BB7), + 0x7530 => char::from_u32(0x0000544D), + 0x7531 => char::from_u32(0x00005496), + 0x7532 => char::from_u32(0x0000549C), + 0x7533 => char::from_u32(0x000054A9), + 0x7534 => char::from_u32(0x0000550E), + 0x7535 => char::from_u32(0x0000554A), + 0x7536 => char::from_u32(0x00005672), + 0x7537 => char::from_u32(0x000056E4), + 0x7538 => char::from_u32(0x00005733), + 0x7539 => char::from_u32(0x00005734), + 0x753A => char::from_u32(0x0000FA10), + 0x753B => char::from_u32(0x00005880), + 0x753C => char::from_u32(0x000059E4), + 0x753D => char::from_u32(0x00005A23), + 0x753E => char::from_u32(0x00005A55), + 0x753F => char::from_u32(0x00005BEC), + 0x7540 => char::from_u32(0x0000FA11), + 0x7541 => char::from_u32(0x000037E2), + 0x7542 => char::from_u32(0x00005EAC), + 0x7543 => char::from_u32(0x00005F34), + 0x7544 => char::from_u32(0x00005F45), + 0x7545 => char::from_u32(0x00005FB7), + 0x7546 => char::from_u32(0x00006017), + 0x7547 => char::from_u32(0x0000FA6B), + 0x7548 => char::from_u32(0x00006130), + 0x7549 => char::from_u32(0x00006624), + 0x754A => char::from_u32(0x000066C8), + 0x754B => char::from_u32(0x000066D9), + 0x754C => char::from_u32(0x000066FA), + 0x754D => char::from_u32(0x000066FB), + 0x754E => char::from_u32(0x00006852), + 0x754F => char::from_u32(0x00009FC4), + 0x7550 => char::from_u32(0x00006911), + 0x7551 => char::from_u32(0x0000693B), + 0x7552 => char::from_u32(0x00006A45), + 0x7553 => char::from_u32(0x00006A91), + 0x7554 => char::from_u32(0x00006ADB), + 0x7555 => char::from_u32(0x000233CC), + 0x7556 => char::from_u32(0x000233FE), + 0x7557 => char::from_u32(0x000235C4), + 0x7558 => char::from_u32(0x00006BF1), + 0x7559 => char::from_u32(0x00006CE0), + 0x755A => char::from_u32(0x00006D2E), + 0x755B => char::from_u32(0x0000FA45), + 0x755C => char::from_u32(0x00006DBF), + 0x755D => char::from_u32(0x00006DCA), + 0x755E => char::from_u32(0x00006DF8), + 0x755F => char::from_u32(0x0000FA46), + 0x7560 => char::from_u32(0x00006F5E), + 0x7561 => char::from_u32(0x00006FF9), + 0x7562 => char::from_u32(0x00007064), + 0x7563 => char::from_u32(0x0000FA6C), + 0x7564 => char::from_u32(0x000242EE), + 0x7565 => char::from_u32(0x00007147), + 0x7566 => char::from_u32(0x000071C1), + 0x7567 => char::from_u32(0x00007200), + 0x7568 => char::from_u32(0x0000739F), + 0x7569 => char::from_u32(0x000073A8), + 0x756A => char::from_u32(0x000073C9), + 0x756B => char::from_u32(0x000073D6), + 0x756C => char::from_u32(0x0000741B), + 0x756D => char::from_u32(0x00007421), + 0x756E => char::from_u32(0x0000FA4A), + 0x756F => char::from_u32(0x00007426), + 0x7570 => char::from_u32(0x0000742A), + 0x7571 => char::from_u32(0x0000742C), + 0x7572 => char::from_u32(0x00007439), + 0x7573 => char::from_u32(0x0000744B), + 0x7574 => char::from_u32(0x00003EDA), + 0x7575 => char::from_u32(0x00007575), + 0x7576 => char::from_u32(0x00007581), + 0x7577 => char::from_u32(0x00007772), + 0x7578 => char::from_u32(0x00004093), + 0x7579 => char::from_u32(0x000078C8), + 0x757A => char::from_u32(0x000078E0), + 0x757B => char::from_u32(0x00007947), + 0x757C => char::from_u32(0x000079AE), + 0x757D => char::from_u32(0x00009FC6), + 0x757E => char::from_u32(0x00004103), + 0x7621 => char::from_u32(0x00009FC5), + 0x7622 => char::from_u32(0x000079DA), + 0x7623 => char::from_u32(0x00007A1E), + 0x7624 => char::from_u32(0x00007B7F), + 0x7625 => char::from_u32(0x00007C31), + 0x7626 => char::from_u32(0x00004264), + 0x7627 => char::from_u32(0x00007D8B), + 0x7628 => char::from_u32(0x00007FA1), + 0x7629 => char::from_u32(0x00008118), + 0x762A => char::from_u32(0x0000813A), + 0x762B => char::from_u32(0x0000FA6D), + 0x762C => char::from_u32(0x000082AE), + 0x762D => char::from_u32(0x0000845B), + 0x762E => char::from_u32(0x000084DC), + 0x762F => char::from_u32(0x000084EC), + 0x7630 => char::from_u32(0x00008559), + 0x7631 => char::from_u32(0x000085CE), + 0x7632 => char::from_u32(0x00008755), + 0x7633 => char::from_u32(0x000087EC), + 0x7634 => char::from_u32(0x0000880B), + 0x7635 => char::from_u32(0x000088F5), + 0x7636 => char::from_u32(0x000089D2), + 0x7637 => char::from_u32(0x00008AF6), + 0x7638 => char::from_u32(0x00008DCE), + 0x7639 => char::from_u32(0x00008FBB), + 0x763A => char::from_u32(0x00008FF6), + 0x763B => char::from_u32(0x000090DD), + 0x763C => char::from_u32(0x00009127), + 0x763D => char::from_u32(0x0000912D), + 0x763E => char::from_u32(0x000091B2), + 0x763F => char::from_u32(0x00009233), + 0x7640 => char::from_u32(0x00009288), + 0x7641 => char::from_u32(0x00009321), + 0x7642 => char::from_u32(0x00009348), + 0x7643 => char::from_u32(0x00009592), + 0x7644 => char::from_u32(0x000096DE), + 0x7645 => char::from_u32(0x00009903), + 0x7646 => char::from_u32(0x00009940), + 0x7647 => char::from_u32(0x00009AD9), + 0x7648 => char::from_u32(0x00009BD6), + 0x7649 => char::from_u32(0x00009DD7), + 0x764A => char::from_u32(0x00009EB4), + 0x764B => char::from_u32(0x00009EB5), + _ => None, + } +} diff --git a/crates/mirakurun-core/src/epg.rs b/crates/mirakurun-core/src/epg.rs index da1a660..da35939 100644 --- a/crates/mirakurun-core/src/epg.rs +++ b/crates/mirakurun-core/src/epg.rs @@ -5,12 +5,13 @@ use std::collections::{BTreeMap, HashMap, HashSet}; -use encoding_rs::EUC_JP; use mirakurun_types::{ - Program, ProgramGenre, ProgramVideo, ProgramVideoResolution, ProgramVideoType, program_id, + 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}, }; @@ -22,6 +23,7 @@ pub struct EitCollector { framer: PacketFramer, assembler: SectionAssembler, programs: HashMap, + states: HashMap, sections_seen: HashSet<(u8, u16, u8, u8)>, packet_count: u64, section_count: u64, @@ -40,7 +42,7 @@ impl EitCollector { continue; } for section in self.assembler.push(packet) { - if parse_eit_section(§ion, &mut self.programs) + 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]]), @@ -72,7 +74,61 @@ impl EitCollector { } } -fn parse_eit_section(section: &[u8], programs: &mut HashMap) -> bool { +#[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 @@ -80,10 +136,14 @@ fn parse_eit_section(section: &[u8], programs: &mut HashMap) -> bo { 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 { @@ -105,36 +165,64 @@ fn parse_eit_section(section: &[u8], programs: &mut HashMap) -> bo if descriptor_end > end { return false; } - if let Some(start_at) = decode_mjd_time(start) { - let id = program_id(network_id, service_id, event_id); - let program = programs.entry(id).or_insert_with(|| Program { - id, - event_id, - service_id, - network_id, - start_at, - 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, - }); - program.start_at = start_at; - program.duration = decode_bcd_duration(duration).unwrap_or(1); - program.is_free = section[offset + 10] & 0x10 == 0; - parse_descriptors(§ion[offset + 12..descriptor_end], program); + 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) { +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 { @@ -145,43 +233,81 @@ fn parse_descriptors(mut descriptors: &[u8], program: &mut Program) { } let body = &descriptors[2..end]; match descriptors[0] { - 0x4d => parse_short_event(body, program), - 0x4e => parse_extended_event(body, program), - 0x50 => parse_video_component(body, program), - 0x54 => parse_content(body, program), + 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], program: &mut Program) { +fn parse_short_event(body: &[u8]) -> Option<(String, String)> { if body.len() < 5 { - return; + return None; } let name_length = usize::from(body[3]); - let Some(text_length_offset) = 4usize.checked_add(name_length) else { - return; - }; + let text_length_offset = 4usize.checked_add(name_length)?; if text_length_offset >= body.len() { - return; + return None; } let text_length = usize::from(body[text_length_offset]); let text_start = text_length_offset + 1; - let Some(text_end) = text_start.checked_add(text_length) else { - return; - }; + let text_end = text_start.checked_add(text_length)?; if text_end > body.len() { - return; + return None; } - program.name = Some(decode_arib(&body[4..text_length_offset])); - program.description = Some(decode_arib(&body[text_start..text_end])); + Some(( + decode_arib(&body[4..text_length_offset]), + decode_arib(&body[text_start..text_end]), + )) } -fn parse_extended_event(body: &[u8], program: &mut Program) { +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; @@ -189,44 +315,78 @@ fn parse_extended_event(body: &[u8], program: &mut Program) { if items_end > body.len() { return; } - let extended = program.extended.get_or_insert_with(BTreeMap::new); - let mut items = &body[5..items_end]; - let mut previous_key = String::new(); + 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 Some(description_end) = 1usize.checked_add(description_length) else { - return; - }; + let description_end = 1usize.checked_add(description_length)?; if description_end >= items.len() { - return; + return None; } let item_length = usize::from(items[description_end]); let item_start = description_end + 1; - let Some(item_end) = item_start.checked_add(item_length) else { - return; - }; + let item_end = item_start.checked_add(item_length)?; if item_end > items.len() { - return; + return None; } - let decoded_key = decode_arib(&items[1..description_end]); - let key = if decoded_key.is_empty() { - previous_key.clone() - } else { - previous_key.clone_from(&decoded_key); - decoded_key - }; - let value = decode_arib(&items[item_start..item_end]); - extended - .entry(key) - .and_modify(|current| current.push_str(&value)) - .or_insert(value); + 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 parse_video_component(body: &[u8], program: &mut Program) { +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; + return None; } let stream_content = body[0] & 0x0f; let component_type = body[1]; @@ -234,7 +394,7 @@ fn parse_video_component(body: &[u8], program: &mut Program) { 1 => ProgramVideoType::Mpeg2, 5 => ProgramVideoType::H264, 9 => ProgramVideoType::H265, - _ => return, + _ => return None, }; let resolution = match component_type { 0x01..=0x04 => ProgramVideoResolution::I480, @@ -245,17 +405,17 @@ fn parse_video_component(body: &[u8], program: &mut Program) { 0xc1..=0xc4 => ProgramVideoResolution::P720, 0xd1..=0xd4 => ProgramVideoResolution::P240, 0xe1..=0xe4 => ProgramVideoResolution::P1080, - _ => return, + _ => return None, }; - program.video = Some(ProgramVideo { + Some(ProgramVideo { video_type, resolution, stream_content, component_type, - }); + }) } -fn parse_content(body: &[u8], program: &mut Program) { +fn parse_content(body: &[u8]) -> Option> { let genres = body .chunks_exact(2) .map(|content| ProgramGenre { @@ -265,9 +425,133 @@ fn parse_content(body: &[u8], program: &mut Program) { un2: content[1] & 0x0f, }) .collect::>(); - if !genres.is_empty() { - program.genres = Some(genres); + (!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 { @@ -289,6 +573,17 @@ fn decode_mjd_time(value: &[u8]) -> Option { 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; @@ -312,152 +607,12 @@ fn decode_bcd(value: u8) -> Option { (high <= 9 && low <= 9).then_some(high * 10 + low) } -#[derive(Debug, Clone, Copy)] -enum GraphicSet { - Kanji, - Alphanumeric, - Hiragana, - Katakana, -} - -pub(crate) fn decode_arib(input: &[u8]) -> String { - let mut output = String::new(); - let mut sets = [ - GraphicSet::Kanji, - GraphicSet::Alphanumeric, - GraphicSet::Hiragana, - GraphicSet::Katakana, - ]; - let mut left = 0usize; - let mut right = 2usize; - let mut single_shift = None; - let mut offset = 0; - while offset < input.len() { - let byte = input[offset]; - match byte { - 0x0e => left = 1, - 0x0f => left = 0, - 0x19 => single_shift = Some(2), - 0x1d => single_shift = Some(3), - 0x1b => { - offset += consume_escape(&input[offset..], &mut sets, &mut left, &mut right) - .saturating_sub(1); - } - 0x0d => output.push('\n'), - 0x20 | 0xa0 => output.push(' '), - 0x21..=0x7e => { - let set = single_shift.take().unwrap_or(left); - let consumed = decode_graphic(sets[set], &input[offset..], false, &mut output); - offset += consumed.saturating_sub(1); - } - 0xa1..=0xfe => { - let consumed = decode_graphic(sets[right], &input[offset..], true, &mut output); - offset += consumed.saturating_sub(1); - } - _ => {} - } - offset += 1; - } - output -} - -fn decode_graphic(set: GraphicSet, input: &[u8], high_bit: bool, output: &mut String) -> usize { - let first = if high_bit { input[0] & 0x7f } else { input[0] }; - match set { - GraphicSet::Kanji => { - let Some(second) = input.get(1).copied() else { - return 1; - }; - let second = if high_bit { second & 0x7f } else { second }; - append_euc_jp(&[first | 0x80, second | 0x80], output); - 2 - } - GraphicSet::Alphanumeric => { - output.push(char::from(first)); - 1 - } - GraphicSet::Hiragana => { - append_euc_jp(&[0xa4, first | 0x80], output); - 1 - } - GraphicSet::Katakana => { - append_euc_jp(&[0xa5, first | 0x80], output); - 1 - } - } -} - -fn append_euc_jp(bytes: &[u8], output: &mut String) { - let (decoded, had_errors) = EUC_JP.decode_without_bom_handling(bytes); - if had_errors { - output.push('\u{fffd}'); - } else { - output.push_str(&decoded); - } -} - -fn consume_escape( - input: &[u8], - sets: &mut [GraphicSet; 4], - left: &mut usize, - right: &mut usize, -) -> usize { - let Some(second) = input.get(1).copied() else { - return 1; - }; - match second { - 0x6e => *left = 2, - 0x6f => *left = 3, - 0x7c => *right = 3, - 0x7d => *right = 2, - 0x7e => *right = 1, - 0x28..=0x2b => { - let Some(code) = input.get(2).copied() else { - return 2; - }; - if let Some(set) = graphic_set(code) { - sets[usize::from(second - 0x28)] = set; - } - return 3; - } - 0x24 => { - let Some(third) = input.get(2).copied() else { - return 2; - }; - if matches!(third, 0x28..=0x2b) { - let Some(code) = input.get(3).copied() else { - return 3; - }; - if let Some(set) = graphic_set(code) { - sets[usize::from(third - 0x28)] = set; - } - return 4; - } - if let Some(set) = graphic_set(third) { - sets[0] = set; - } - return 3; - } - _ => {} - } - 2 -} - -const fn graphic_set(code: u8) -> Option { - match code { - 0x42 | 0x39 => Some(GraphicSet::Kanji), - 0x4a | 0x36 => Some(GraphicSet::Alphanumeric), - 0x30 | 0x37 => Some(GraphicSet::Hiragana), - 0x31 | 0x38 | 0x49 => Some(GraphicSet::Katakana), - _ => None, - } -} - #[cfg(test)] mod tests { use crate::ts::{PACKET_SIZE, crc32_mpeg2}; + use mirakurun_types::ProgramRelatedItemType; - use super::{EitCollector, decode_arib, decode_bcd_duration, decode_mjd_time}; + use super::{EitCollector, VersionRecord, decode_arib, decode_bcd_duration, decode_mjd_time}; #[test] fn decodes_common_arib_character_sets() { @@ -506,4 +661,108 @@ mod tests { 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 + } } diff --git a/crates/mirakurun-core/src/lib.rs b/crates/mirakurun-core/src/lib.rs index cbc0a35..ff4c5de 100644 --- a/crates/mirakurun-core/src/lib.rs +++ b/crates/mirakurun-core/src/lib.rs @@ -3,10 +3,12 @@ // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. +mod arib; pub mod config; pub mod epg; pub mod error; pub mod filter; +pub mod logo; pub mod persistence; pub mod service; pub mod ts; diff --git a/crates/mirakurun-core/src/logo.rs b/crates/mirakurun-core/src/logo.rs new file mode 100644 index 0000000..e563253 --- /dev/null +++ b/crates/mirakurun-core/src/logo.rs @@ -0,0 +1,637 @@ +// 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::{HashMap, HashSet}; + +use crate::{ + filter::SectionAssembler, + ts::{Packet, PacketFramer, crc32_mpeg2}, +}; + +const PID_CDT: u16 = 0x0029; +const TABLE_ID_CDT: u8 = 0xc8; +const TABLE_ID_DII: u8 = 0x3b; +const TABLE_ID_DDB: u8 = 0x3c; +const MAX_LOGO_MODULE_SIZE: usize = 64 * 1024 * 1024; + +/// A decoded type-5 broadcaster logo and the services which reference it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogoData { + pub network_id: u16, + pub logo_id: u16, + pub services: Vec<(u16, u16)>, + pub png: Vec, +} + +#[derive(Debug)] +struct Download { + module_id: u16, + module_version: u8, + block_size: usize, + bytes: Vec, + received_blocks: HashSet, + received_bytes: usize, +} + +/// Collects terrestrial CDT and satellite DSM-CC logo data from a transport stream. +#[derive(Debug, Default)] +pub struct LogoCollector { + framer: PacketFramer, + cdt_assembler: SectionAssembler, + dsmcc_assemblers: HashMap, + downloads: HashMap, + logos: HashMap<(u16, u16), LogoData>, +} + +impl LogoCollector { + 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 { + let Ok(packet) = Packet::new(&packet_bytes) else { + continue; + }; + if packet.pid() == PID_CDT { + for section in self.cdt_assembler.push(packet) { + if let Some(logo) = parse_cdt(§ion) { + self.insert_logo(logo); + } + } + continue; + } + + let pid = packet.pid(); + if !self.dsmcc_assemblers.contains_key(&pid) && !is_dsmcc_start(packet) { + continue; + } + let sections = self.dsmcc_assemblers.entry(pid).or_default().push(packet); + for section in sections { + self.push_dsmcc(§ion); + } + } + } + + #[must_use] + pub fn has_logo(&self) -> bool { + !self.logos.is_empty() + } + + #[must_use] + pub fn contains(&self, network_id: u16, logo_id: u16) -> bool { + self.logos.contains_key(&(network_id, logo_id)) + } + + #[must_use] + pub fn into_logos(self) -> Vec { + let mut logos = self.logos.into_values().collect::>(); + logos.sort_by_key(|logo| (logo.network_id, logo.logo_id)); + logos + } + + fn push_dsmcc(&mut self, section: &[u8]) { + if section.len() < 12 || crc32_mpeg2(section) != 0 || section[5] & 0x01 == 0 { + return; + } + match section[0] { + TABLE_ID_DII => { + if let Some((download_id, download)) = parse_dii(section) { + self.downloads.entry(download_id).or_insert(download); + } + } + TABLE_ID_DDB => { + let Some(block) = parse_ddb(section) else { + return; + }; + let Some(download) = self.downloads.get_mut(&block.download_id) else { + return; + }; + if download.module_id != block.module_id + || download.module_version != block.module_version + || !download.received_blocks.insert(block.block_number) + { + return; + } + let offset = download + .block_size + .saturating_mul(usize::from(block.block_number)); + if offset >= download.bytes.len() { + return; + } + let copy_len = block.data.len().min(download.bytes.len() - offset); + download.bytes[offset..offset + copy_len].copy_from_slice(&block.data[..copy_len]); + download.received_bytes = download.received_bytes.saturating_add(copy_len); + if download.received_bytes < download.bytes.len() { + return; + } + let Some(download) = self.downloads.remove(&block.download_id) else { + return; + }; + for logo in parse_logo_module(&download.bytes) { + self.insert_logo(logo); + } + } + _ => {} + } + } + + fn insert_logo(&mut self, mut logo: LogoData) { + let key = (logo.network_id, logo.logo_id); + if let Some(previous) = self.logos.remove(&key) { + logo.services.extend(previous.services); + logo.services.sort_unstable(); + logo.services.dedup(); + } + self.logos.insert(key, logo); + } +} + +fn is_dsmcc_start(packet: Packet<'_>) -> bool { + if !packet.payload_unit_start() { + return false; + } + let Some(payload) = packet.payload() else { + return false; + }; + let Some(table_offset) = payload + .first() + .copied() + .map(usize::from) + .and_then(|pointer| 1usize.checked_add(pointer)) + else { + return false; + }; + payload + .get(table_offset) + .is_some_and(|table_id| matches!(*table_id, TABLE_ID_DII | TABLE_ID_DDB)) +} + +fn parse_cdt(section: &[u8]) -> Option { + if section.len() < 24 + || section[0] != TABLE_ID_CDT + || section[5] & 0x01 == 0 + || crc32_mpeg2(section) != 0 + || section[10] != 0x01 + { + return None; + } + let network_id = u16::from_be_bytes([section[8], section[9]]); + let descriptors_length = (usize::from(section[11] & 0x0f) << 8) | usize::from(section[12]); + let module_start = 13usize.checked_add(descriptors_length)?; + let module_end = section.len().checked_sub(4)?; + let module = section.get(module_start..module_end)?; + if module.len() < 7 || module[0] != 0x05 { + return None; + } + let logo_id = (u16::from(module[1] & 0x01) << 8) | u16::from(module[2]); + let data_size = usize::from(u16::from_be_bytes([module[5], module[6]])); + let data_end = 7usize.checked_add(data_size)?; + let png = decode_indexed_png(module.get(7..data_end)?)?; + Some(LogoData { + network_id, + logo_id, + services: Vec::new(), + png, + }) +} + +fn parse_dii(section: &[u8]) -> Option<(u32, Download)> { + if section.first().copied() != Some(TABLE_ID_DII) { + return None; + } + let body = section.get(8..section.len().checked_sub(4)?)?; + if body.len() < 34 || u16::from_be_bytes([body[2], body[3]]) != 0x1002 { + return None; + } + let adaptation_length = usize::from(body[9]); + let mut offset = 12usize.checked_add(adaptation_length)?; + let download_id = read_u32(body, offset)?; + offset += 4; + let block_size = usize::from(read_u16(body, offset)?); + offset += 12; + let compatibility_length = usize::from(read_u16(body, offset)?); + offset = offset.checked_add(2)?.checked_add(compatibility_length)?; + let module_count = usize::from(read_u16(body, offset)?); + offset += 2; + for _ in 0..module_count { + let module_id = read_u16(body, offset)?; + let module_size = usize::try_from(read_u32(body, offset + 2)?).ok()?; + let module_version = *body.get(offset + 6)?; + let info_length = usize::from(*body.get(offset + 7)?); + let info_start = offset + 8; + let info_end = info_start.checked_add(info_length)?; + let info = body.get(info_start..info_end)?; + if module_size <= MAX_LOGO_MODULE_SIZE && is_logo_module(info) { + return Some(( + download_id, + Download { + module_id, + module_version, + block_size: block_size.max(1), + bytes: vec![0; module_size], + received_blocks: HashSet::new(), + received_bytes: 0, + }, + )); + } + offset = info_end; + } + None +} + +fn is_logo_module(mut descriptors: &[u8]) -> bool { + while descriptors.len() >= 2 { + let end = 2usize.saturating_add(usize::from(descriptors[1])); + if end > descriptors.len() { + return false; + } + if descriptors[0] == 0x02 && matches!(&descriptors[2..end], b"LOGO-05" | b"CS_LOGO-05") { + return true; + } + descriptors = &descriptors[end..]; + } + false +} + +struct DownloadBlock<'a> { + download_id: u32, + module_id: u16, + module_version: u8, + block_number: u16, + data: &'a [u8], +} + +fn parse_ddb(section: &[u8]) -> Option> { + if section.first().copied() != Some(TABLE_ID_DDB) { + return None; + } + let body = section.get(8..section.len().checked_sub(4)?)?; + if body.len() < 18 || u16::from_be_bytes([body[2], body[3]]) != 0x1003 { + return None; + } + let download_id = read_u32(body, 4)?; + let adaptation_length = usize::from(body[9]); + let offset = 12usize.checked_add(adaptation_length)?; + Some(DownloadBlock { + download_id, + module_id: read_u16(body, offset)?, + module_version: *body.get(offset + 2)?, + block_number: read_u16(body, offset + 4)?, + data: body.get(offset + 6..)?, + }) +} + +fn parse_logo_module(module: &[u8]) -> Vec { + if module.len() < 3 || module[0] != 0x05 { + return Vec::new(); + } + let count = usize::from(u16::from_be_bytes([module[1], module[2]])); + let mut offset = 3; + let mut output = Vec::new(); + for _ in 0..count { + let Some(logo_bits) = module.get(offset..offset + 2) else { + break; + }; + let logo_id = (u16::from(logo_bits[0] & 0x01) << 8) | u16::from(logo_bits[1]); + let Some(&service_count) = module.get(offset + 2) else { + break; + }; + offset += 3; + let mut services = Vec::new(); + for _ in 0..service_count { + let Some(service) = module.get(offset..offset + 6) else { + return output; + }; + services.push(( + u16::from_be_bytes([service[0], service[1]]), + u16::from_be_bytes([service[4], service[5]]), + )); + offset += 6; + } + let Some(data_size) = module.get(offset..offset + 2) else { + break; + }; + let data_size = usize::from(u16::from_be_bytes([data_size[0], data_size[1]])); + offset += 2; + let Some(data) = module.get(offset..offset.saturating_add(data_size)) else { + break; + }; + offset += data_size; + let Some(png) = decode_indexed_png(data) else { + continue; + }; + for network_id in services + .iter() + .map(|(network_id, _)| *network_id) + .collect::>() + { + output.push(LogoData { + network_id, + logo_id, + services: services + .iter() + .copied() + .filter(|(service_network_id, _)| *service_network_id == network_id) + .collect(), + png: png.clone(), + }); + } + } + output +} + +fn decode_indexed_png(data: &[u8]) -> Option> { + const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; + if data.len() < 33 || data.get(..8)? != PNG_SIGNATURE || data.get(25).copied()? != 3 { + return None; + } + let palette = logo_palette(); + let mut plte = Vec::with_capacity(palette.len() * 3); + let mut trns = Vec::with_capacity(palette.len()); + for [red, green, blue, alpha] in palette { + plte.extend_from_slice(&[red, green, blue]); + trns.push(alpha); + } + let plte = png_chunk(*b"PLTE", &plte); + let trns = png_chunk(*b"tRNS", &trns); + let mut png = Vec::with_capacity(data.len() + plte.len() + trns.len()); + png.extend_from_slice(&data[..33]); + png.extend_from_slice(&plte); + png.extend_from_slice(&trns); + png.extend_from_slice(&data[33..]); + Some(png) +} + +fn png_chunk(kind: [u8; 4], data: &[u8]) -> Vec { + let mut chunk = Vec::with_capacity(12 + data.len()); + chunk.extend_from_slice(&u32::try_from(data.len()).unwrap_or(u32::MAX).to_be_bytes()); + chunk.extend_from_slice(&kind); + chunk.extend_from_slice(data); + let checksum = crc32_ieee(&chunk[4..]); + chunk.extend_from_slice(&checksum.to_be_bytes()); + chunk +} + +fn crc32_ieee(data: &[u8]) -> u32 { + let mut crc = u32::MAX; + for byte in data { + crc ^= u32::from(*byte); + for _ in 0..8 { + crc = (crc >> 1) ^ (0xedb8_8320 & 0u32.wrapping_sub(crc & 1)); + } + } + !crc +} + +fn logo_palette() -> Vec<[u8; 4]> { + const COLORS: [[u8; 3]; 65] = [ + [0, 0, 0], + [255, 0, 0], + [0, 255, 0], + [255, 255, 0], + [0, 0, 255], + [255, 0, 255], + [0, 255, 255], + [255, 255, 255], + [0, 0, 0], + [170, 0, 0], + [0, 170, 0], + [170, 170, 0], + [0, 0, 170], + [170, 0, 170], + [0, 170, 170], + [170, 170, 170], + [0, 0, 85], + [0, 85, 0], + [0, 85, 85], + [0, 85, 170], + [0, 85, 255], + [0, 170, 85], + [0, 170, 255], + [0, 255, 85], + [0, 255, 170], + [85, 0, 0], + [85, 0, 85], + [85, 0, 170], + [85, 0, 255], + [85, 85, 0], + [85, 85, 85], + [85, 85, 170], + [85, 85, 255], + [85, 170, 0], + [85, 170, 85], + [85, 170, 170], + [85, 170, 255], + [85, 255, 0], + [85, 255, 85], + [85, 255, 170], + [85, 255, 255], + [170, 0, 85], + [170, 0, 255], + [170, 85, 0], + [170, 85, 85], + [170, 85, 170], + [170, 85, 255], + [170, 170, 85], + [170, 170, 255], + [170, 255, 0], + [170, 255, 85], + [170, 255, 170], + [170, 255, 255], + [255, 0, 85], + [255, 0, 255], + [255, 85, 0], + [255, 85, 85], + [255, 85, 170], + [255, 85, 255], + [255, 170, 0], + [255, 170, 85], + [255, 170, 170], + [255, 170, 255], + [255, 255, 85], + [255, 255, 255], + ]; + let mut palette = Vec::with_capacity(128); + palette.extend(COLORS.into_iter().enumerate().map(|(index, color)| { + [ + color[0], + color[1], + color[2], + if index == 8 { 0 } else { 255 }, + ] + })); + palette.extend( + COLORS + .into_iter() + .enumerate() + .filter(|(index, _)| *index != 8) + .map(|(_, color)| [color[0], color[1], color[2], 128]), + ); + palette +} + +fn read_u16(bytes: &[u8], offset: usize) -> Option { + Some(u16::from_be_bytes([ + *bytes.get(offset)?, + *bytes.get(offset + 1)?, + ])) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Option { + Some(u32::from_be_bytes([ + *bytes.get(offset)?, + *bytes.get(offset + 1)?, + *bytes.get(offset + 2)?, + *bytes.get(offset + 3)?, + ])) +} + +#[cfg(test)] +mod tests { + use crate::ts::{PACKET_SIZE, crc32_mpeg2}; + + use super::{LogoCollector, decode_indexed_png, parse_logo_module}; + + fn indexed_png() -> Vec { + let mut png = vec![0; 33]; + png[..8].copy_from_slice(b"\x89PNG\r\n\x1a\n"); + png[8..12].copy_from_slice(&13u32.to_be_bytes()); + png[12..16].copy_from_slice(b"IHDR"); + png[25] = 3; + png.extend_from_slice(&[0, 0, 0, 0, b'I', b'E', b'N', b'D', 0, 0, 0, 0]); + png + } + + fn finish_section(mut section: Vec) -> Vec { + let section_length = section.len() - 3 + 4; + section[1] = 0xf0 | 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()); + section + } + + fn logo_module() -> Vec { + let source = indexed_png(); + let mut module = vec![ + 0x05, 0x00, 0x01, 0x00, 0x09, 0x01, 0x00, 0x04, 0x00, 0x01, 0x00, 0x65, + ]; + module.extend_from_slice( + &u16::try_from(source.len()) + .expect("logo data length") + .to_be_bytes(), + ); + module.extend_from_slice(&source); + module + } + + #[test] + fn injects_arib_palette_into_indexed_png() { + let decoded = decode_indexed_png(&indexed_png()).expect("decode PNG"); + assert_eq!(&decoded[37..41], b"PLTE"); + assert_eq!(&decoded[436..440], b"tRNS"); + assert_eq!(decoded.len(), indexed_png().len() + 399 + 141); + } + + #[test] + fn collects_type_five_logo_from_cdt() { + let source = indexed_png(); + let mut section = vec![ + 0xc8, 0xf0, 0x00, 0x00, 0x01, 0xc1, 0x00, 0x00, 0x7f, 0xf0, 0x01, 0xf0, 0x00, 0x05, + 0x00, 0x07, 0x00, 0x01, + ]; + section.extend_from_slice( + &u16::try_from(source.len()) + .expect("logo data length") + .to_be_bytes(), + ); + section.extend_from_slice(&source); + let section = finish_section(section); + let mut packet = [0xff; PACKET_SIZE]; + packet[0] = 0x47; + packet[1] = 0x40; + packet[2] = 0x29; + packet[3] = 0x10; + packet[4] = 0; + packet[5..5 + section.len()].copy_from_slice(§ion); + + let mut collector = LogoCollector::default(); + collector.push(&packet); + let logos = collector.into_logos(); + assert_eq!(logos.len(), 1); + assert_eq!(logos[0].network_id, 0x7ff0); + assert_eq!(logos[0].logo_id, 7); + assert_eq!(&logos[0].png[37..41], b"PLTE"); + } + + #[test] + fn parses_satellite_logo_service_associations() { + let logos = parse_logo_module(&logo_module()); + assert_eq!(logos.len(), 1); + assert_eq!(logos[0].network_id, 4); + assert_eq!(logos[0].logo_id, 9); + assert_eq!(logos[0].services, vec![(4, 101)]); + } + + #[test] + fn reassembles_satellite_logo_from_dii_and_ddb() { + let module = logo_module(); + let download_id = 0x1122_3344_u32; + let mut dii_body = vec![ + 0x11, 0x03, 0x10, 0x02, 0x00, 0x00, 0x00, 0x01, 0xff, 0x00, 0x00, 0x00, + ]; + dii_body.extend_from_slice(&download_id.to_be_bytes()); + dii_body.extend_from_slice(&4066_u16.to_be_bytes()); + dii_body.extend_from_slice(&[0, 0]); + dii_body.extend_from_slice(&[0; 8]); + dii_body.extend_from_slice(&0_u16.to_be_bytes()); + dii_body.extend_from_slice(&1_u16.to_be_bytes()); + dii_body.extend_from_slice(&1_u16.to_be_bytes()); + dii_body.extend_from_slice( + &u32::try_from(module.len()) + .expect("module size") + .to_be_bytes(), + ); + dii_body.extend_from_slice(&[1, 9, 0x02, 7]); + dii_body.extend_from_slice(b"LOGO-05"); + dii_body.extend_from_slice(&0_u16.to_be_bytes()); + let mut dii = vec![0x3b, 0xf0, 0x00, 0x00, 0x00, 0xc1, 0x00, 0x00]; + dii.extend_from_slice(&dii_body); + let dii = finish_section(dii); + + let message_length = u16::try_from(module.len() + 6).expect("DDB message size"); + let mut ddb_body = vec![ + 0x11, + 0x03, + 0x10, + 0x03, + 0x11, + 0x22, + 0x33, + 0x44, + 0xff, + 0x00, + (message_length >> 8) as u8, + (message_length & 0xff) as u8, + 0x00, + 0x01, + 0x01, + 0xff, + 0x00, + 0x00, + ]; + ddb_body.extend_from_slice(&module); + let mut ddb = vec![0x3c, 0xf0, 0x00, 0x00, 0x00, 0xc1, 0x00, 0x00]; + ddb.extend_from_slice(&ddb_body); + let ddb = finish_section(ddb); + + let mut collector = LogoCollector::default(); + collector.push_dsmcc(&dii); + collector.push_dsmcc(&ddb); + let logos = collector.into_logos(); + assert_eq!(logos.len(), 1); + assert_eq!(logos[0].network_id, 4); + assert_eq!(logos[0].logo_id, 9); + } +} diff --git a/crates/mirakurun-core/src/service.rs b/crates/mirakurun-core/src/service.rs index 34e8221..5d86431 100644 --- a/crates/mirakurun-core/src/service.rs +++ b/crates/mirakurun-core/src/service.rs @@ -8,22 +8,26 @@ use std::collections::{HashMap, HashSet}; use mirakurun_types::{Channel, ConfigChannel, Service, service_item_id}; use crate::{ - epg::decode_arib, + arib::decode as decode_arib, filter::SectionAssembler, ts::{Packet, PacketFramer, crc32_mpeg2}, }; const PID_SDT: u16 = 0x0011; +const PID_NIT: u16 = 0x0010; +const TABLE_ID_NIT_ACTUAL: u8 = 0x40; const TABLE_ID_SDT_ACTUAL: u8 = 0x42; #[derive(Debug)] pub struct ServiceCollector { channel: ConfigChannel, framer: PacketFramer, - assembler: SectionAssembler, + sdt_assembler: SectionAssembler, + nit_assembler: SectionAssembler, services: HashMap, sections_seen: HashSet<(u8, u8)>, last_section: Option, + network: Option<(u16, Option)>, packet_count: u64, } @@ -33,10 +37,12 @@ impl ServiceCollector { Self { channel, framer: PacketFramer::default(), - assembler: SectionAssembler::default(), + sdt_assembler: SectionAssembler::default(), + nit_assembler: SectionAssembler::default(), services: HashMap::new(), sections_seen: HashSet::new(), last_section: None, + network: None, packet_count: 0, } } @@ -49,32 +55,42 @@ impl ServiceCollector { let Ok(packet) = Packet::new(&packet_bytes) else { continue; }; - if packet.pid() != PID_SDT { - continue; - } - for section in self.assembler.push(packet) { - let Some(parsed) = parse_sdt_section(§ion, &self.channel) else { - continue; - }; - self.last_section = Some(parsed.last_section); - self.sections_seen - .insert((parsed.version, parsed.section_number)); - for service in parsed.services { - self.services.insert(service.id, service); + match packet.pid() { + PID_NIT => { + for section in self.nit_assembler.push(packet) { + if let Some(network) = parse_nit_section(§ion) { + self.network = Some(network); + } + } } + PID_SDT => { + for section in self.sdt_assembler.push(packet) { + let Some(parsed) = parse_sdt_section(§ion, &self.channel) else { + continue; + }; + self.last_section = Some(parsed.last_section); + self.sections_seen + .insert((parsed.version, parsed.section_number)); + for service in parsed.services { + self.services.insert(service.id, service); + } + } + } + _ => {} } } } #[must_use] pub fn is_complete(&self) -> bool { - self.last_section.is_some_and(|last| { - (0..=last).all(|section| { - self.sections_seen - .iter() - .any(|(_, seen_section)| *seen_section == section) + self.network.is_some() + && self.last_section.is_some_and(|last| { + (0..=last).all(|section| { + self.sections_seen + .iter() + .any(|(_, seen_section)| *seen_section == section) + }) }) - }) } #[must_use] @@ -83,13 +99,77 @@ impl ServiceCollector { } #[must_use] - pub fn into_services(self) -> Vec { + pub fn into_services(mut self) -> Vec { + if let Some((network_id, Some(remote_control_key_id))) = self.network { + for service in self.services.values_mut() { + if service.network_id == network_id { + service.remote_control_key_id = Some(remote_control_key_id); + } + } + } let mut services = self.services.into_values().collect::>(); services.sort_by_key(|service| service.service_id); services } } +fn parse_nit_section(section: &[u8]) -> Option<(u16, Option)> { + if section.len() < 16 + || section[0] != TABLE_ID_NIT_ACTUAL + || section[5] & 0x01 == 0 + || crc32_mpeg2(section) != 0 + { + return None; + } + let network_id = u16::from_be_bytes([section[3], section[4]]); + let end = section.len().checked_sub(4)?; + let network_descriptors_length = + (usize::from(section[8] & 0x0f) << 8) | usize::from(section[9]); + let transport_length_offset = 10usize.checked_add(network_descriptors_length)?; + if transport_length_offset + 2 > end { + return None; + } + let transport_loop_length = (usize::from(section[transport_length_offset] & 0x0f) << 8) + | usize::from(section[transport_length_offset + 1]); + let mut offset = transport_length_offset + 2; + let transport_end = offset.checked_add(transport_loop_length)?; + if transport_end > end { + return None; + } + let mut remote_control_key_id = None; + while offset + 6 <= transport_end { + let descriptors_length = + (usize::from(section[offset + 4] & 0x0f) << 8) | usize::from(section[offset + 5]); + let descriptors_start = offset + 6; + let descriptors_end = descriptors_start.checked_add(descriptors_length)?; + if descriptors_end > transport_end { + return None; + } + remote_control_key_id = + parse_remote_control_key_id(§ion[descriptors_start..descriptors_end]); + if remote_control_key_id.is_some() { + break; + } + offset = descriptors_end; + } + Some((network_id, remote_control_key_id)) +} + +fn parse_remote_control_key_id(mut descriptors: &[u8]) -> Option { + while descriptors.len() >= 2 { + let length = usize::from(descriptors[1]); + let end = 2usize.checked_add(length)?; + if end > descriptors.len() { + return None; + } + if descriptors[0] == 0xcd && length >= 1 { + return Some(descriptors[2]); + } + descriptors = &descriptors[end..]; + } + None +} + struct ParsedSdt { version: u8, section_number: u8, @@ -122,7 +202,7 @@ fn parse_sdt_section(section: &[u8], channel: &ConfigChannel) -> Option Option Option Option<(u8, String)> { +fn parse_service_descriptor(mut descriptors: &[u8]) -> Option<(u8, String, Option)> { + let mut service = None; + let mut logo_id = None; while descriptors.len() >= 2 { let length = usize::from(descriptors[1]); let end = 2usize.checked_add(length)?; if end > descriptors.len() { return None; } - if descriptors[0] == 0x48 { - let body = &descriptors[2..end]; - if body.len() < 3 { - return None; + let body = &descriptors[2..end]; + match descriptors[0] { + 0x48 => { + if body.len() < 3 { + return None; + } + let provider_length = usize::from(body[1]); + let name_length_offset = 2usize.checked_add(provider_length)?; + if name_length_offset >= body.len() { + return None; + } + let name_length = usize::from(body[name_length_offset]); + let name_start = name_length_offset + 1; + let name_end = name_start.checked_add(name_length)?; + if name_end > body.len() { + return None; + } + service = Some((body[0], decode_arib(&body[name_start..name_end]))); } - let provider_length = usize::from(body[1]); - let name_length_offset = 2usize.checked_add(provider_length)?; - if name_length_offset >= body.len() { - return None; + 0xcf if body.len() >= 3 && matches!(body[0], 1 | 2) => { + logo_id = Some((u16::from(body[1] & 0x01) << 8) | u16::from(body[2])); } - let name_length = usize::from(body[name_length_offset]); - let name_start = name_length_offset + 1; - let name_end = name_start.checked_add(name_length)?; - if name_end > body.len() { - return None; - } - return Some((body[0], decode_arib(&body[name_start..name_end]))); + _ => {} } descriptors = &descriptors[end..]; } - None + service.map(|(service_type, name)| (service_type, name, logo_id)) } #[cfg(test)] @@ -193,29 +281,45 @@ mod tests { use super::ServiceCollector; - #[test] - fn discovers_service_from_sdt() { - let descriptor = [ - 0x48, 0x0c, 0x01, 0x00, 0x09, 0x0e, b'T', b'e', b's', b't', b' ', b'T', b'V', b'!', - ]; - let mut section = vec![ - 0x42, 0xf0, 0x00, 0x00, 0x01, 0xc1, 0x00, 0x00, 0x7f, 0xf0, 0xff, 0x00, 0x65, 0xfc, - 0x80, 0x0e, - ]; - section.extend_from_slice(&descriptor); + fn section_packet(pid: u16, section: &[u8]) -> [u8; PACKET_SIZE] { + let mut packet = [0xff; PACKET_SIZE]; + packet[0] = 0x47; + packet[1] = 0x40 | u8::try_from(pid >> 8).expect("PID"); + packet[2] = u8::try_from(pid & 0xff).expect("PID"); + packet[3] = 0x10; + packet[4] = 0; + packet[5..5 + section.len()].copy_from_slice(section); + packet + } + + fn finish_section(mut section: Vec) -> Vec { let section_length = section.len() - 3 + 4; section[1] = 0xf0 | 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()); + section + } - let mut packet = [0xff; PACKET_SIZE]; - packet[0] = 0x47; - packet[1] = 0x40; - packet[2] = 0x11; - packet[3] = 0x10; - packet[4] = 0; - packet[5..5 + section.len()].copy_from_slice(§ion); + #[test] + fn discovers_service_from_sdt() { + let descriptor = [ + 0x48, 0x0c, 0x01, 0x00, 0x09, 0x0e, b'T', b'e', b's', b't', b' ', b'T', b'V', b'!', + 0xcf, 0x03, 0x02, 0x00, 0x07, + ]; + let section = finish_section( + vec![ + 0x42, 0xf0, 0x00, 0x00, 0x01, 0xc1, 0x00, 0x00, 0x7f, 0xf0, 0xff, 0x00, 0x65, 0xfc, + 0x80, 0x13, + ] + .into_iter() + .chain(descriptor) + .collect(), + ); + let nit = finish_section(vec![ + 0x40, 0xf0, 0x00, 0x7f, 0xf0, 0xc1, 0x00, 0x00, 0xf0, 0x00, 0xf0, 0x09, 0x00, 0x01, + 0x7f, 0xf0, 0xf0, 0x03, 0xcd, 0x01, 0x05, + ]); let mut collector = ServiceCollector::new(ConfigChannel { name: "channel".into(), @@ -231,12 +335,15 @@ mod tests { freq: None, polarity: None, }); - collector.push(&packet); + collector.push(§ion_packet(0x10, &nit)); + collector.push(§ion_packet(0x11, §ion)); assert!(collector.is_complete()); let services = collector.into_services(); assert_eq!(services.len(), 1); assert_eq!(services[0].name, "Test TV!"); assert_eq!(services[0].network_id, 0x7ff0); assert_eq!(services[0].service_id, 101); + assert_eq!(services[0].logo_id, Some(7)); + assert_eq!(services[0].remote_control_key_id, Some(5)); } } diff --git a/crates/mirakurun-core/src/tuner.rs b/crates/mirakurun-core/src/tuner.rs index 61053d3..21ccfe9 100644 --- a/crates/mirakurun-core/src/tuner.rs +++ b/crates/mirakurun-core/src/tuner.rs @@ -68,23 +68,31 @@ impl Drop for TunerSubscription { #[derive(Debug, Clone)] pub struct TunerManager { devices: Arc>, + respawn_count: Arc, } impl TunerManager { #[must_use] pub fn new(configs: &[ConfigTuner]) -> Self { + let respawn_count = Arc::new(AtomicU64::new(0)); let devices = configs .iter() .filter(|config| config.is_disabled != Some(true)) .cloned() .enumerate() - .map(|(index, config)| DeviceHandle::spawn(index, config)) + .map(|(index, config)| DeviceHandle::spawn(index, config, respawn_count.clone())) .collect(); Self { devices: Arc::new(devices), + respawn_count, } } + #[must_use] + pub fn respawn_count(&self) -> u64 { + self.respawn_count.load(Ordering::Relaxed) + } + /// Subscribes to a compatible tuner, sharing an existing multiplex when possible. /// /// # Errors @@ -173,11 +181,11 @@ struct DeviceHandle { } impl DeviceHandle { - fn spawn(index: usize, config: ConfigTuner) -> Self { + fn spawn(index: usize, config: ConfigTuner, respawn_count: Arc) -> Self { let (command, receiver) = mpsc::channel(DEVICE_COMMAND_CAPACITY); let actor_command = command.clone(); tokio::spawn(async move { - DeviceActor::new(index, config, actor_command) + DeviceActor::new(index, config, actor_command, respawn_count) .run(receiver) .await; }); @@ -265,10 +273,16 @@ struct DeviceActor { fault: bool, fatal_count: u8, generation: u64, + respawn_count: Arc, } impl DeviceActor { - fn new(index: usize, config: ConfigTuner, command_sender: mpsc::Sender) -> Self { + fn new( + index: usize, + config: ConfigTuner, + command_sender: mpsc::Sender, + respawn_count: Arc, + ) -> Self { Self { index, config, @@ -282,6 +296,7 @@ impl DeviceActor { fault: false, fatal_count: 0, generation: 0, + respawn_count, } } @@ -461,6 +476,7 @@ impl DeviceActor { self.release(); return; }; + self.respawn_count.fetch_add(1, Ordering::Relaxed); tracing::warn!(device = self.index, "respawning tuner after unexpected EOF"); if let Err(error) = self.start_process(channel).await { tracing::error!(device = self.index, %error, "failed to respawn tuner"); diff --git a/crates/mirakurun-rs/src/api.rs b/crates/mirakurun-rs/src/api.rs index c7f89d9..9fe3bbf 100644 --- a/crates/mirakurun-rs/src/api.rs +++ b/crates/mirakurun-rs/src/api.rs @@ -6,9 +6,11 @@ use std::{ collections::{BTreeMap, HashMap}, convert::Infallible, + future::pending, io, process::Stdio, sync::{Arc, atomic::Ordering}, + time::Duration, }; use axum::{ @@ -751,6 +753,8 @@ pub async fn build_status(state: &AppState) -> Status { }, error_count: ErrorCount { buffer_overflow: state.buffer_overflow_count.load(Ordering::Relaxed), + tuner_device_respawn: state.tuners.respawn_count(), + decoder_respawn: state.decoder_respawn_count.load(Ordering::Relaxed), ..ErrorCount::default() }, timer_accuracy: TimerAccuracy::default(), @@ -1278,6 +1282,7 @@ async fn head_program_stream( } } +#[allow(clippy::too_many_lines)] async fn stream_channel( state: Arc, channel: ConfigChannel, @@ -1305,61 +1310,192 @@ async fn stream_channel( .await .map_err(|error| ApiFailure::unavailable(error.to_string()))?; let user_id = subscription.user_id.clone(); - let mut decoder = subscription - .decoder - .as_deref() - .map(spawn_decoder) - .transpose()?; + let decoder_command = subscription.decoder.clone(); + if let Some(command) = decoder_command.as_deref() { + let _ = parse_process_command(command)?; + } let stream_state = state.clone(); let output = async_stream::stream! { let _guard = StreamGuard::new(stream_state.clone()); - let _decoder_guard = decoder + let _decoder_guard = decoder_command .as_ref() .map(|_| DecoderGuard::new(stream_state.clone())); let mut service_filter = service_id.map(mirakurun_core::filter::ServiceFilter::new); let mut decoder_buffer = vec![0_u8; 32 * 1024]; + let mut decoder: Option = None; + let mut decoder_dead_count = 0_u8; + let mut decoder_fallback = decoder_command.is_none(); + let mut decoder_retry_at = decoder_command + .as_ref() + .map(|_| tokio::time::Instant::now()); + let mut decoder_response_deadline = None; + let mut decoder_waiting_for_output = false; let mut source_open = true; loop { if stream_expired(end_at) { break; } if let Some(process) = decoder.as_mut() { - if source_open { + let event = if source_open { tokio::select! { source = subscription.receiver.recv() => { - match source { - Ok(chunk) => { - let filtered = filter_stream_chunk(&mut service_filter, &chunk); - if !filtered.is_empty() - && process.stdin.write_all(&filtered).await.is_err() - { - break; - } - } - Err(broadcast::error::RecvError::Lagged(skipped)) => { - stream_state.buffer_overflow_count.fetch_add(skipped, Ordering::Relaxed); - break; - } - Err(broadcast::error::RecvError::Closed) => { - source_open = false; - let _ = process.stdin.shutdown().await; - } - } + DecoderStreamEvent::Source(source) } decoder_read = process.stdout.read(&mut decoder_buffer) => { - match decoder_read { - Ok(0) | Err(_) => break, - Ok(length) => { - yield Ok::(Bytes::copy_from_slice(&decoder_buffer[..length])); + DecoderStreamEvent::Output(decoder_read) + } + () = wait_for_decoder_deadline(decoder_response_deadline) => { + DecoderStreamEvent::NoResponse + } + } + } else { + tokio::select! { + decoder_read = process.stdout.read(&mut decoder_buffer) => { + DecoderStreamEvent::Output(decoder_read) + } + () = wait_for_decoder_deadline(decoder_response_deadline) => { + DecoderStreamEvent::NoResponse + } + } + }; + + let mut decoder_failure = None; + match event { + DecoderStreamEvent::Source(Ok(chunk)) => { + let filtered = filter_stream_chunk(&mut service_filter, &chunk); + if !filtered.is_empty() { + let first_input = !decoder_waiting_for_output; + if first_input { + decoder_waiting_for_output = true; + decoder_response_deadline = + Some(tokio::time::Instant::now() + DECODER_RESPONSE_TIMEOUT); + } + let write_deadline = decoder_response_deadline + .unwrap_or_else(|| { + tokio::time::Instant::now() + DECODER_RESPONSE_TIMEOUT + }); + match tokio::time::timeout_at( + write_deadline, + process.stdin.write_all(&filtered), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(_)) => decoder_failure = Some("stdin closed"), + Err(_) => decoder_failure = Some("stdin write timed out"), + } + } + } + DecoderStreamEvent::Source(Err(broadcast::error::RecvError::Lagged(skipped))) => { + stream_state.buffer_overflow_count.fetch_add(skipped, Ordering::Relaxed); + service_filter = + service_id.map(mirakurun_core::filter::ServiceFilter::new); + decoder_failure = Some("source buffer overflow"); + } + DecoderStreamEvent::Source(Err(broadcast::error::RecvError::Closed)) => { + source_open = false; + let _ = process.stdin.shutdown().await; + decoder_response_deadline = + Some(tokio::time::Instant::now() + DECODER_RESPONSE_TIMEOUT); + } + DecoderStreamEvent::Output(Ok(0)) => { + decoder_failure = Some("stdout closed"); + } + DecoderStreamEvent::Output(Err(_)) => { + decoder_failure = Some("stdout read failed"); + } + DecoderStreamEvent::Output(Ok(length)) => { + decoder_waiting_for_output = false; + decoder_response_deadline = if source_open { + None + } else { + Some(tokio::time::Instant::now() + DECODER_RESPONSE_TIMEOUT) + }; + yield Ok::(Bytes::copy_from_slice(&decoder_buffer[..length])); + } + DecoderStreamEvent::NoResponse => { + decoder_failure = Some("no response"); + } + } + + if let Some(reason) = decoder_failure { + decoder = None; + decoder_response_deadline = None; + decoder_waiting_for_output = false; + if !source_open { + break; + } + if record_decoder_failure(&mut decoder_dead_count) { + decoder_fallback = true; + stream_state + .log(format!( + "decoder failed ({reason}); falling back to pass-through" + )) + .await; + } else { + decoder_retry_at = + Some(tokio::time::Instant::now() + DECODER_RESPAWN_DELAY); + stream_state + .log(format!( + "decoder failed ({reason}); scheduling respawn {decoder_dead_count}/{MAX_DECODER_RESPAWNS}" + )) + .await; + } + } + } else if !decoder_fallback { + let retry_at = decoder_retry_at.unwrap_or_else(tokio::time::Instant::now); + tokio::select! { + biased; + () = tokio::time::sleep_until(retry_at) => { + if decoder_dead_count > 0 { + stream_state + .decoder_respawn_count + .fetch_add(1, Ordering::Relaxed); + } + let command = decoder_command.as_deref().unwrap_or_default(); + match spawn_decoder(command) { + Ok(process) => { + decoder = Some(process); + decoder_retry_at = None; + decoder_response_deadline = None; + decoder_waiting_for_output = false; + } + Err(error) => { + let reason = error + .reason + .as_deref() + .unwrap_or("failed to start decoder") + .to_owned(); + if record_decoder_failure(&mut decoder_dead_count) { + decoder_fallback = true; + stream_state + .log(format!( + "decoder failed ({reason}); falling back to pass-through" + )) + .await; + } else { + decoder_retry_at = + Some(tokio::time::Instant::now() + DECODER_RESPAWN_DELAY); + stream_state + .log(format!( + "decoder failed ({reason}); scheduling respawn {decoder_dead_count}/{MAX_DECODER_RESPAWNS}" + )) + .await; } } } } - } else { - match process.stdout.read(&mut decoder_buffer).await { - Ok(0) | Err(_) => break, - Ok(length) => { - yield Ok::(Bytes::copy_from_slice(&decoder_buffer[..length])); + source = subscription.receiver.recv() => { + match source { + Ok(chunk) => { + let _ = filter_stream_chunk(&mut service_filter, &chunk); + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + stream_state.buffer_overflow_count.fetch_add(skipped, Ordering::Relaxed); + service_filter = + service_id.map(mirakurun_core::filter::ServiceFilter::new); + } + Err(broadcast::error::RecvError::Closed) => break, } } } @@ -1373,7 +1509,8 @@ async fn stream_channel( } Err(broadcast::error::RecvError::Lagged(skipped)) => { stream_state.buffer_overflow_count.fetch_add(skipped, Ordering::Relaxed); - break; + service_filter = + service_id.map(mirakurun_core::filter::ServiceFilter::new); } Err(broadcast::error::RecvError::Closed) => break, } @@ -1383,6 +1520,29 @@ async fn stream_channel( Ok(stream_response(Body::from_stream(output), &user_id)) } +const MAX_DECODER_RESPAWNS: u8 = 3; +const DECODER_RESPAWN_DELAY: Duration = Duration::from_millis(1500); +const DECODER_RESPONSE_TIMEOUT: Duration = Duration::from_millis(1500); + +enum DecoderStreamEvent { + Source(Result), + Output(io::Result), + NoResponse, +} + +async fn wait_for_decoder_deadline(deadline: Option) { + if let Some(deadline) = deadline { + tokio::time::sleep_until(deadline).await; + } else { + pending::<()>().await; + } +} + +fn record_decoder_failure(dead_count: &mut u8) -> bool { + *dead_count = dead_count.saturating_add(1); + *dead_count > MAX_DECODER_RESPAWNS +} + fn stream_expired(end_at: Option) -> bool { end_at.is_some_and(|end_at| AppState::now_ms() >= end_at) } @@ -1698,3 +1858,37 @@ where { Json(value).into_response() } + +#[cfg(test)] +mod tests { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use super::{MAX_DECODER_RESPAWNS, record_decoder_failure, spawn_decoder}; + + #[test] + fn decoder_falls_back_after_three_respawns() { + let mut dead_count = 0; + for expected in 1..=MAX_DECODER_RESPAWNS { + assert!(!record_decoder_failure(&mut dead_count)); + assert_eq!(dead_count, expected); + } + assert!(record_decoder_failure(&mut dead_count)); + } + + #[tokio::test] + async fn decoder_process_round_trips_transport_data() { + let mut decoder = spawn_decoder("/bin/cat").expect("start pass-through decoder"); + decoder + .stdin + .write_all(b"transport-stream") + .await + .expect("write decoder input"); + let mut output = vec![0; 16]; + decoder + .stdout + .read_exact(&mut output) + .await + .expect("read decoder output"); + assert_eq!(&output, b"transport-stream"); + } +} diff --git a/crates/mirakurun-rs/src/jobs.rs b/crates/mirakurun-rs/src/jobs.rs index 1df301a..0a47121 100644 --- a/crates/mirakurun-rs/src/jobs.rs +++ b/crates/mirakurun-rs/src/jobs.rs @@ -6,14 +6,15 @@ use std::{ collections::{HashMap, HashSet}, sync::Arc, - time::Duration, + time::{Duration, SystemTime}, }; use anyhow::{Result, bail}; use chrono::{DateTime, Datelike, Local, Timelike}; use mirakurun_core::{ epg::EitCollector, - persistence::{channels_integrity, save_json_db}, + logo::{LogoCollector, LogoData}, + persistence::{atomic_write, channels_integrity, ensure_parent, save_json_db}, service::ServiceCollector, tuner::StreamRequest, }; @@ -340,8 +341,9 @@ async fn epg_gather(state: &Arc) -> Result<()> { .unwrap_or(10 * 60 * 1000); for (network_id, channel) in targets { let _guard = GatheringNetworkGuard::new(state.clone(), network_id).await; - let programs = gather_network(state, channel, retrieval_time).await?; - merge_epg(state, network_id, programs).await?; + let gathered = gather_network(state, network_id, channel, retrieval_time).await?; + merge_logo_data(state, gathered.logos).await?; + merge_epg(state, network_id, gathered.programs).await?; } Ok(()) } @@ -421,8 +423,12 @@ async fn merge_services( .collect::>(); for service in &mut discovered { if let Some(previous) = old_on_channel.get(&service.id) { - service.logo_id = previous.logo_id; - service.remote_control_key_id = previous.remote_control_key_id; + if service.logo_id.is_none() { + service.logo_id = previous.logo_id; + } + if service.remote_control_key_id.is_none() { + service.remote_control_key_id = previous.remote_control_key_id; + } service.epg_ready = previous.epg_ready; service.epg_updated_at = previous.epg_updated_at; } @@ -517,9 +523,10 @@ async fn epg_targets(state: &Arc) -> Vec<(u16, ConfigChannel)> { async fn gather_network( state: &Arc, + network_id: u16, channel: ConfigChannel, retrieval_time_ms: u64, -) -> Result> { +) -> Result { let mut subscription = state .tuners .subscribe(StreamRequest { @@ -537,10 +544,17 @@ async fn gather_network( let mut last_section = started; let mut section_count = 0; let mut collector = EitCollector::default(); + let mut logo_collector = LogoCollector::default(); + let expected_logos = stale_logo_keys(state, network_id).await; loop { let now = Instant::now(); + let logos_complete = expected_logos + .iter() + .all(|(network_id, logo_id)| logo_collector.contains(*network_id, *logo_id)); if now >= deadline - || (section_count > 0 && now.duration_since(last_section) >= Duration::from_secs(3)) + || (section_count > 0 + && now.duration_since(last_section) >= Duration::from_secs(3) + && logos_complete) { break; } @@ -550,6 +564,7 @@ async fn gather_network( match tokio::time::timeout(wait, subscription.receiver.recv()).await { Ok(Ok(chunk)) => { collector.push(&chunk); + logo_collector.push(&chunk); if collector.section_count() != section_count { section_count = collector.section_count(); last_section = Instant::now(); @@ -569,7 +584,95 @@ async fn gather_network( if collector.section_count() == 0 { bail!("no EIT sections received before the EPG retrieval timeout"); } - Ok(collector.into_programs()) + Ok(GatheredNetwork { + programs: collector.into_programs(), + logos: logo_collector.into_logos(), + }) +} + +struct GatheredNetwork { + programs: Vec, + logos: Vec, +} + +async fn stale_logo_keys(state: &AppState, network_id: u16) -> HashSet<(u16, u16)> { + let interval = state + .config + .read() + .await + .server + .logo_data_interval + .unwrap_or(7 * DAY_MS); + let services = state.services.read().await.clone(); + let mut pending_keys = HashSet::new(); + for service in services + .iter() + .filter(|service| service.network_id == network_id) + { + let Some(logo_id) = service.logo_id else { + continue; + }; + let path = state + .paths + .logo_data_dir + .join(format!("{}_{}.png", service.network_id, logo_id)); + let fresh = tokio::fs::metadata(path) + .await + .ok() + .and_then(|metadata| metadata.modified().ok()) + .and_then(|modified| SystemTime::now().duration_since(modified).ok()) + .is_some_and(|age| u64::try_from(age.as_millis()).unwrap_or(u64::MAX) <= interval); + if !fresh { + pending_keys.insert((service.network_id, logo_id)); + } + } + pending_keys +} + +async fn merge_logo_data(state: &Arc, logos: Vec) -> Result<()> { + if logos.is_empty() { + return Ok(()); + } + let mut services = state.services.read().await.clone(); + let mut updated_service_ids = HashSet::new(); + for logo in &logos { + let path = state + .paths + .logo_data_dir + .join(format!("{}_{}.png", logo.network_id, logo.logo_id)); + ensure_parent(&path, "logo data").await?; + atomic_write(&path, &logo.png, "logo data").await?; + for &(network_id, service_id) in &logo.services { + let service = services.iter_mut().find(|service| { + service.network_id == network_id && service.service_id == service_id + }); + if let Some(service) = service { + if service.logo_id != Some(logo.logo_id) { + service.logo_id = Some(logo.logo_id); + updated_service_ids.insert(service.id); + } + } + } + } + + if !updated_service_ids.is_empty() { + let channels = state.config.read().await.channels.clone(); + let integrity = channels_integrity(&channels)?; + save_json_db(&state.paths.services_db, &services, &integrity).await?; + state.services.write().await.clone_from(&services); + for service in services + .iter() + .filter(|service| updated_service_ids.contains(&service.id)) + { + state + .emit_event(EventResource::Service, EventType::Update, service) + .await; + } + } + state + .log(format!("stored {} broadcast logo images", logos.len())) + .await; + Ok(()) } async fn merge_epg( @@ -588,7 +691,7 @@ async fn merge_epg( .collect::>(); for program in &incoming { if let Some(current) = programs.iter_mut().find(|current| current.id == program.id) { - current.clone_from(program); + merge_program(current, program); } else { programs.push(program.clone()); } @@ -638,6 +741,36 @@ async fn merge_epg( Ok(()) } +fn merge_program(current: &mut mirakurun_types::Program, incoming: &mirakurun_types::Program) { + current.start_at = incoming.start_at; + current.duration = incoming.duration; + current.is_free = incoming.is_free; + if incoming.name.is_some() { + current.name.clone_from(&incoming.name); + } + if incoming.description.is_some() { + current.description.clone_from(&incoming.description); + } + if incoming.genres.is_some() { + current.genres.clone_from(&incoming.genres); + } + if incoming.video.is_some() { + current.video.clone_from(&incoming.video); + } + if incoming.audios.is_some() { + current.audios.clone_from(&incoming.audios); + } + if incoming.series.is_some() { + current.series.clone_from(&incoming.series); + } + if incoming.extended.is_some() { + current.extended.clone_from(&incoming.extended); + } + if incoming.related_items.is_some() { + current.related_items.clone_from(&incoming.related_items); + } +} + struct GatheringNetworkGuard { state: Arc, network_id: u16, diff --git a/crates/mirakurun-rs/src/state.rs b/crates/mirakurun-rs/src/state.rs index c6c9435..1d292c5 100644 --- a/crates/mirakurun-rs/src/state.rs +++ b/crates/mirakurun-rs/src/state.rs @@ -55,6 +55,7 @@ pub struct AppState { pub stream_count: AtomicU64, pub decoder_count: AtomicU64, pub buffer_overflow_count: AtomicU64, + pub decoder_respawn_count: AtomicU64, pub shutdown: CancellationToken, pub restart_requested: AtomicBool, } @@ -100,6 +101,7 @@ impl AppState { stream_count: AtomicU64::new(0), decoder_count: AtomicU64::new(0), buffer_overflow_count: AtomicU64::new(0), + decoder_respawn_count: AtomicU64::new(0), shutdown: CancellationToken::new(), restart_requested: AtomicBool::new(false), }))