First commit

This commit is contained in:
CyberRex
2026-07-31 14:39:33 +09:00
commit 38670efd46
103 changed files with 22514 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
[package]
name = "mirakurun-client"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
bytes.workspace = true
futures-util.workspace = true
http.workspace = true
http-body-util.workspace = true
hyper.workspace = true
hyper-util.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,180 @@
// 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::{
path::{Path, PathBuf},
pin::Pin,
};
use bytes::Bytes;
use futures_util::{Stream, StreamExt};
use http::{Request, StatusCode};
use http_body_util::{BodyExt, Empty};
use hyper::client::conn::http1;
use hyper_util::rt::TokioIo;
use serde::de::DeserializeOwned;
use tokio::net::{TcpStream, UnixStream};
pub type ByteStream =
Pin<Box<dyn Stream<Item = std::result::Result<Bytes, ClientError>> + Send + 'static>>;
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("HTTP request failed: {0}")]
Hyper(#[from] hyper::Error),
#[error("failed to connect to {endpoint}: {source}")]
Connect {
endpoint: String,
#[source]
source: std::io::Error,
},
#[error("request returned HTTP {0}")]
Status(StatusCode),
#[error("invalid response JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("invalid HTTP request: {0}")]
Request(#[from] http::Error),
}
pub type Result<T> = std::result::Result<T, ClientError>;
#[derive(Debug, Clone)]
enum Endpoint {
Http { host: String, port: u16 },
Unix(PathBuf),
}
#[derive(Debug, Clone)]
pub struct MirakurunClient {
endpoint: Endpoint,
}
impl MirakurunClient {
#[must_use]
pub fn http(host: &str, port: u16) -> Self {
Self {
endpoint: Endpoint::Http {
host: host.to_owned(),
port,
},
}
}
#[must_use]
pub fn unix(path: impl AsRef<Path>) -> Self {
Self {
endpoint: Endpoint::Unix(path.as_ref().to_path_buf()),
}
}
/// Fetches and deserializes a JSON response.
///
/// # Errors
///
/// Returns an error when the connection, HTTP status, body read, or JSON
/// deserialization fails.
pub async fn get_json<T>(&self, path: &str) -> Result<T>
where
T: DeserializeOwned,
{
let response = self.get(path).await?;
if !response.status().is_success() {
return Err(ClientError::Status(response.status()));
}
let bytes = response.into_body().collect().await?.to_bytes();
Ok(serde_json::from_slice(&bytes)?)
}
/// Opens an HTTP response body as an asynchronous byte stream.
///
/// # Errors
///
/// Returns an error when the connection fails or the server returns a
/// non-success status.
pub async fn get_stream(&self, path: &str) -> Result<ByteStream> {
let response = self.get(path).await?;
if !response.status().is_success() {
return Err(ClientError::Status(response.status()));
}
Ok(Box::pin(
response
.into_body()
.into_data_stream()
.map(|result| result.map_err(ClientError::Hyper)),
))
}
async fn get(&self, path: &str) -> Result<http::Response<hyper::body::Incoming>> {
match &self.endpoint {
Endpoint::Http { host, port } => tcp_get(host, *port, path).await,
Endpoint::Unix(socket) => unix_get(socket, path).await,
}
}
}
async fn unix_get(socket: &Path, path: &str) -> Result<http::Response<hyper::body::Incoming>> {
let stream = UnixStream::connect(socket)
.await
.map_err(|source| ClientError::Connect {
endpoint: socket.display().to_string(),
source,
})?;
send_request(stream, path, "localhost").await
}
async fn tcp_get(
host: &str,
port: u16,
path: &str,
) -> Result<http::Response<hyper::body::Incoming>> {
let stream = TcpStream::connect((host, port))
.await
.map_err(|source| ClientError::Connect {
endpoint: format!("{host}:{port}"),
source,
})?;
send_request(stream, path, &format!("{host}:{port}")).await
}
async fn send_request<T>(
stream: T,
path: &str,
host: &str,
) -> Result<http::Response<hyper::body::Incoming>>
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let (mut sender, connection) = http1::handshake(TokioIo::new(stream)).await?;
tokio::spawn(async move {
if let Err(error) = connection.await {
tracing::debug!(%error, "Unix socket HTTP connection closed");
}
});
let request = Request::builder()
.method(http::Method::GET)
.uri(normalize_path(path))
.header(http::header::HOST, host)
.body(Empty::<Bytes>::new())?;
Ok(sender.send_request(request).await?)
}
fn normalize_path(path: &str) -> String {
if path.starts_with('/') {
path.to_owned()
} else {
format!("/{path}")
}
}
#[cfg(test)]
mod tests {
use super::normalize_path;
#[test]
fn request_path_is_absolute() {
assert_eq!(normalize_path("api/status"), "/api/status");
assert_eq!(normalize_path("/api/status"), "/api/status");
}
}

View File

@@ -0,0 +1,28 @@
[package]
name = "mirakurun-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
base64.workspace = true
bytes.workspace = true
chrono.workspace = true
encoding_rs.workspace = true
mirakurun-types = { path = "../mirakurun-types" }
nix.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml_ng.workspace = true
sha2.workspace = true
tempfile.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,229 @@
// 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::{
env,
path::{Path, PathBuf},
};
use mirakurun_types::{
ConfigChannel, ConfigServer, ConfigTuner, default_ipv4_ranges, default_ipv6_ranges,
default_origins,
};
use serde::{Serialize, de::DeserializeOwned};
use crate::{
Result,
error::CoreError,
persistence::{atomic_write, ensure_parent},
};
pub const DEFAULT_SERVER_CONFIG_PATH: &str = "/usr/local/etc/mirakurun/server.yml";
pub const DEFAULT_TUNERS_CONFIG_PATH: &str = "/usr/local/etc/mirakurun/tuners.yml";
pub const DEFAULT_CHANNELS_CONFIG_PATH: &str = "/usr/local/etc/mirakurun/channels.yml";
pub const DEFAULT_SERVICES_DB_PATH: &str = "/usr/local/var/db/mirakurun/services.json";
pub const DEFAULT_PROGRAMS_DB_PATH: &str = "/usr/local/var/db/mirakurun/programs.json";
pub const DEFAULT_LOGO_DATA_DIR_PATH: &str = "/usr/local/var/db/mirakurun/logo-data";
const DEFAULT_SERVER_YAML: &str = "logLevel: 2\npath: /var/run/mirakurun.sock\nport: 40772\n";
const DEFAULT_LIST_YAML: &str = "[]\n";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigPaths {
pub server: PathBuf,
pub tuners: PathBuf,
pub channels: PathBuf,
pub services_db: PathBuf,
pub programs_db: PathBuf,
pub logo_data_dir: PathBuf,
}
impl Default for ConfigPaths {
fn default() -> Self {
Self::from_environment()
}
}
impl ConfigPaths {
#[must_use]
pub fn from_environment() -> Self {
Self {
server: env_path("SERVER_CONFIG_PATH", DEFAULT_SERVER_CONFIG_PATH),
tuners: env_path("TUNERS_CONFIG_PATH", DEFAULT_TUNERS_CONFIG_PATH),
channels: env_path("CHANNELS_CONFIG_PATH", DEFAULT_CHANNELS_CONFIG_PATH),
services_db: env_path("SERVICES_DB_PATH", DEFAULT_SERVICES_DB_PATH),
programs_db: env_path("PROGRAMS_DB_PATH", DEFAULT_PROGRAMS_DB_PATH),
logo_data_dir: env_path("LOGO_DATA_DIR_PATH", DEFAULT_LOGO_DATA_DIR_PATH),
}
}
}
#[derive(Debug, Clone)]
pub struct LoadedConfig {
pub server: ConfigServer,
pub tuners: Vec<ConfigTuner>,
pub channels: Vec<ConfigChannel>,
}
impl LoadedConfig {
/// Loads all Mirakurun configuration files, creating defaults when absent.
///
/// # Errors
///
/// Returns an error when a file cannot be created/read, YAML is invalid,
/// or a validated server setting is outside its accepted range.
pub async fn load(paths: &ConfigPaths) -> Result<Self> {
let mut server: ConfigServer =
load_or_create_yaml(&paths.server, DEFAULT_SERVER_YAML, "server config").await?;
apply_server_defaults(&mut server);
validate_server(&server)?;
let tuners = load_or_create_yaml(&paths.tuners, DEFAULT_LIST_YAML, "tuners config").await?;
let channels =
load_or_create_yaml(&paths.channels, DEFAULT_LIST_YAML, "channels config").await?;
Ok(Self {
server,
tuners,
channels,
})
}
}
/// Saves the server configuration atomically.
///
/// # Errors
///
/// Returns an error when serialization or filesystem operations fail.
pub async fn save_server(path: &Path, config: &ConfigServer) -> Result<()> {
save_yaml(path, config, "server config").await
}
/// Saves the tuner configuration atomically.
///
/// # Errors
///
/// Returns an error when serialization or filesystem operations fail.
pub async fn save_tuners(path: &Path, config: &[ConfigTuner]) -> Result<()> {
save_yaml(path, config, "tuners config").await
}
/// Saves the channel configuration atomically.
///
/// # Errors
///
/// Returns an error when serialization or filesystem operations fail.
pub async fn save_channels(path: &Path, config: &[ConfigChannel]) -> Result<()> {
save_yaml(path, config, "channels config").await
}
async fn load_or_create_yaml<T>(
path: &Path,
default_contents: &str,
kind: &'static str,
) -> Result<T>
where
T: DeserializeOwned,
{
if !path.exists() {
ensure_parent(path, kind).await?;
atomic_write(path, default_contents.as_bytes(), kind).await?;
}
let contents = tokio::fs::read_to_string(path)
.await
.map_err(|source| CoreError::Read {
kind,
path: path.to_path_buf(),
source,
})?;
serde_yaml_ng::from_str(&contents).map_err(|source| CoreError::Yaml {
path: path.to_path_buf(),
source,
})
}
async fn save_yaml<T>(path: &Path, value: &T, kind: &'static str) -> Result<()>
where
T: Serialize + ?Sized,
{
let contents = serde_yaml_ng::to_string(value)?;
ensure_parent(path, kind).await?;
atomic_write(path, contents.as_bytes(), kind).await
}
fn apply_server_defaults(config: &mut ConfigServer) {
if config.allow_ipv4_cidr_ranges.is_empty() {
config.allow_ipv4_cidr_ranges = default_ipv4_ranges();
}
if config.allow_ipv6_cidr_ranges.is_empty() {
config.allow_ipv6_cidr_ranges = default_ipv6_ranges();
}
if config.allow_origins.is_empty() {
config.allow_origins = default_origins();
}
if config.hostname.as_deref().is_none_or(str::is_empty) {
config.hostname = detect_hostname();
}
}
fn validate_server(config: &ConfigServer) -> Result<()> {
if let Some(value) = config.job_max_running {
if !(1..=100).contains(&value) {
return Err(CoreError::InvalidConfig(
"jobMaxRunning must be between 1 and 100".into(),
));
}
}
if let Some(value) = config.job_max_standby {
if !(1..=100).contains(&value) {
return Err(CoreError::InvalidConfig(
"jobMaxStandby must be between 1 and 100".into(),
));
}
}
Ok(())
}
fn env_path(name: &str, default: &str) -> PathBuf {
env::var_os(name).map_or_else(|| PathBuf::from(default), PathBuf::from)
}
fn detect_hostname() -> Option<String> {
env::var("HOSTNAME")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
std::fs::read_to_string("/etc/hostname")
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
})
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use super::{ConfigPaths, LoadedConfig};
#[tokio::test]
async fn creates_and_loads_missing_configuration() {
let directory = tempdir().expect("create tempdir");
let paths = ConfigPaths {
server: directory.path().join("server.yml"),
tuners: directory.path().join("tuners.yml"),
channels: directory.path().join("channels.yml"),
services_db: directory.path().join("services.json"),
programs_db: directory.path().join("programs.json"),
logo_data_dir: directory.path().join("logo-data"),
};
let loaded = LoadedConfig::load(&paths).await.expect("load config");
assert_eq!(loaded.server.port, Some(40_772));
assert!(loaded.tuners.is_empty());
assert!(loaded.channels.is_empty());
}
}

View File

@@ -0,0 +1,509 @@
// Copyright 2026 Mirakurun contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
use std::collections::{BTreeMap, HashMap, HashSet};
use encoding_rs::EUC_JP;
use mirakurun_types::{
Program, ProgramGenre, ProgramVideo, ProgramVideoResolution, ProgramVideoType, program_id,
};
use crate::{
filter::SectionAssembler,
ts::{Packet, PacketFramer, crc32_mpeg2},
};
const PID_EIT: u16 = 0x0012;
#[derive(Debug, Default)]
pub struct EitCollector {
framer: PacketFramer,
assembler: SectionAssembler,
programs: HashMap<u64, Program>,
sections_seen: HashSet<(u8, u16, u8, u8)>,
packet_count: u64,
section_count: u64,
}
impl EitCollector {
pub fn push(&mut self, chunk: &[u8]) {
let mut packets = Vec::new();
self.framer.push(chunk, |packet| packets.push(*packet));
for packet_bytes in packets {
self.packet_count = self.packet_count.saturating_add(1);
let Ok(packet) = Packet::new(&packet_bytes) else {
continue;
};
if packet.pid() != PID_EIT {
continue;
}
for section in self.assembler.push(packet) {
if parse_eit_section(&section, &mut self.programs)
&& self.sections_seen.insert((
section[0],
u16::from_be_bytes([section[3], section[4]]),
(section[5] >> 1) & 0x1f,
section[6],
))
{
self.section_count = self.section_count.saturating_add(1);
}
}
}
}
#[must_use]
pub const fn packet_count(&self) -> u64 {
self.packet_count
}
#[must_use]
pub const fn section_count(&self) -> u64 {
self.section_count
}
#[must_use]
pub fn into_programs(self) -> Vec<Program> {
let mut programs = self.programs.into_values().collect::<Vec<_>>();
programs.sort_by_key(|program| (program.start_at, program.id));
programs
}
}
fn parse_eit_section(section: &[u8], programs: &mut HashMap<u64, Program>) -> bool {
if section.len() < 18
|| !matches!(section[0], 0x4e..=0x6f)
|| section[5] & 0x01 == 0
|| crc32_mpeg2(section) != 0
{
return false;
}
let is_present_following = matches!(section[0], 0x4e | 0x4f);
if is_present_following && section[6] > 1 {
return false;
}
let service_id = u16::from_be_bytes([section[3], section[4]]);
let network_id = u16::from_be_bytes([section[10], section[11]]);
let Some(end) = section.len().checked_sub(4) else {
return false;
};
let mut offset = 14;
while offset + 12 <= end {
let event_id = u16::from_be_bytes([section[offset], section[offset + 1]]);
let start = &section[offset + 2..offset + 7];
let duration = &section[offset + 7..offset + 10];
let descriptor_length =
(usize::from(section[offset + 10] & 0x0f) << 8) | usize::from(section[offset + 11]);
let Some(descriptor_end) = offset
.checked_add(12)
.and_then(|value| value.checked_add(descriptor_length))
else {
return false;
};
if descriptor_end > end {
return false;
}
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(&section[offset + 12..descriptor_end], program);
}
offset = descriptor_end;
}
true
}
fn parse_descriptors(mut descriptors: &[u8], program: &mut Program) {
while descriptors.len() >= 2 {
let length = usize::from(descriptors[1]);
let Some(end) = 2usize.checked_add(length) else {
return;
};
if end > descriptors.len() {
return;
}
let body = &descriptors[2..end];
match descriptors[0] {
0x4d => parse_short_event(body, program),
0x4e => parse_extended_event(body, program),
0x50 => parse_video_component(body, program),
0x54 => parse_content(body, program),
_ => {}
}
descriptors = &descriptors[end..];
}
}
fn parse_short_event(body: &[u8], program: &mut Program) {
if body.len() < 5 {
return;
}
let name_length = usize::from(body[3]);
let Some(text_length_offset) = 4usize.checked_add(name_length) else {
return;
};
if text_length_offset >= body.len() {
return;
}
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;
};
if text_end > body.len() {
return;
}
program.name = Some(decode_arib(&body[4..text_length_offset]));
program.description = Some(decode_arib(&body[text_start..text_end]));
}
fn parse_extended_event(body: &[u8], program: &mut Program) {
if body.len() < 6 {
return;
}
let items_length = usize::from(body[4]);
let Some(items_end) = 5usize.checked_add(items_length) else {
return;
};
if items_end > body.len() {
return;
}
let extended = program.extended.get_or_insert_with(BTreeMap::new);
let mut items = &body[5..items_end];
let mut previous_key = String::new();
while !items.is_empty() {
let description_length = usize::from(items[0]);
let Some(description_end) = 1usize.checked_add(description_length) else {
return;
};
if description_end >= items.len() {
return;
}
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;
};
if item_end > items.len() {
return;
}
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);
items = &items[item_end..];
}
}
fn parse_video_component(body: &[u8], program: &mut Program) {
if body.len() < 2 {
return;
}
let stream_content = body[0] & 0x0f;
let component_type = body[1];
let video_type = match stream_content {
1 => ProgramVideoType::Mpeg2,
5 => ProgramVideoType::H264,
9 => ProgramVideoType::H265,
_ => return,
};
let resolution = match component_type {
0x01..=0x04 => ProgramVideoResolution::I480,
0x83 => ProgramVideoResolution::P4320,
0x91..=0x94 => ProgramVideoResolution::P2160,
0xa1..=0xa4 => ProgramVideoResolution::P480,
0xb1..=0xb4 => ProgramVideoResolution::I1080,
0xc1..=0xc4 => ProgramVideoResolution::P720,
0xd1..=0xd4 => ProgramVideoResolution::P240,
0xe1..=0xe4 => ProgramVideoResolution::P1080,
_ => return,
};
program.video = Some(ProgramVideo {
video_type,
resolution,
stream_content,
component_type,
});
}
fn parse_content(body: &[u8], program: &mut Program) {
let genres = body
.chunks_exact(2)
.map(|content| ProgramGenre {
lv1: content[0] >> 4,
lv2: content[0] & 0x0f,
un1: content[1] >> 4,
un2: content[1] & 0x0f,
})
.collect::<Vec<_>>();
if !genres.is_empty() {
program.genres = Some(genres);
}
}
fn decode_mjd_time(value: &[u8]) -> Option<u64> {
if value.len() != 5 || value.iter().all(|byte| *byte == 0xff) {
return None;
}
let mjd = i64::from(u16::from_be_bytes([value[0], value[1]]));
let hour = i64::from(decode_bcd(value[2])?);
let minute = i64::from(decode_bcd(value[3])?);
let second = i64::from(decode_bcd(value[4])?);
if hour > 23 || minute > 59 || second > 59 {
return None;
}
let unix_seconds = (mjd - 40_587)
.checked_mul(86_400)?
.checked_add((hour - 9).checked_mul(3600)?)?
.checked_add(minute.checked_mul(60)?)?
.checked_add(second)?;
u64::try_from(unix_seconds.checked_mul(1000)?).ok()
}
fn decode_bcd_duration(value: &[u8]) -> Option<u64> {
if value.len() != 3 || value.iter().all(|byte| *byte == 0xff) {
return None;
}
let hours = u64::from(decode_bcd(value[0])?);
let minutes = u64::from(decode_bcd(value[1])?);
let seconds = u64::from(decode_bcd(value[2])?);
if minutes > 59 || seconds > 59 {
return None;
}
hours
.checked_mul(3600)?
.checked_add(minutes.checked_mul(60)?)?
.checked_add(seconds)?
.checked_mul(1000)
}
fn decode_bcd(value: u8) -> Option<u8> {
let high = value >> 4;
let low = value & 0x0f;
(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<GraphicSet> {
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 super::{EitCollector, decode_arib, decode_bcd_duration, decode_mjd_time};
#[test]
fn decodes_common_arib_character_sets() {
assert_eq!(decode_arib(&[0x0e, b'T', b'e', b's', b't']), "Test");
assert_eq!(decode_arib(&[0x19, 0x22]), "");
assert_eq!(decode_arib(&[0x1d, 0x22]), "");
}
#[test]
fn converts_arib_time_fields() {
assert_eq!(decode_mjd_time(&[0x9e, 0x8b, 0x09, 0x00, 0x00]), Some(0));
assert_eq!(decode_bcd_duration(&[0x01, 0x30, 0x00]), Some(5_400_000));
}
#[test]
fn collects_program_from_eit_packet() {
let short_event = [
0x4d, 0x0d, b'j', b'p', b'n', 0x05, 0x0e, b'T', b'e', b's', b't', 0x03, 0x0e, b'O',
b'K',
];
let mut section = vec![
0x4e, 0xb0, 0x00, 0x00, 0x65, 0xc1, 0x00, 0x00, 0x00, 0x01, 0x7f, 0xf0, 0x00, 0x4e,
0x00, 0x01, 0x9e, 0x8b, 0x09, 0x00, 0x00, 0x01, 0x00, 0x00, 0x80, 0x0f,
];
section.extend_from_slice(&short_event);
let section_length = section.len() - 3 + 4;
section[1] = 0xb0 | u8::try_from(section_length >> 8).expect("section length");
section[2] = u8::try_from(section_length & 0xff).expect("section length");
let crc = crc32_mpeg2(&section);
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(&section);
let mut collector = EitCollector::default();
collector.push(&packet);
let programs = collector.into_programs();
assert_eq!(programs.len(), 1);
assert_eq!(programs[0].name.as_deref(), Some("Test"));
assert_eq!(programs[0].description.as_deref(), Some("OK"));
assert_eq!(programs[0].duration, 3_600_000);
assert!(programs[0].is_free);
}
}

View File

@@ -0,0 +1,48 @@
// 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::path::PathBuf;
#[derive(Debug, thiserror::Error)]
pub enum CoreError {
#[error("failed to read {kind} from {path}: {source}")]
Read {
kind: &'static str,
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to write {kind} to {path}: {source}")]
Write {
kind: &'static str,
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("invalid YAML in {path}: {source}")]
Yaml {
path: PathBuf,
#[source]
source: serde_yaml_ng::Error,
},
#[error("invalid JSON in {path}: {source}")]
Json {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("failed to serialize JSON: {0}")]
JsonSerialize(#[from] serde_json::Error),
#[error("failed to serialize YAML: {0}")]
YamlSerialize(#[from] serde_yaml_ng::Error),
#[error("blocking task failed: {0}")]
Join(#[from] tokio::task::JoinError),
#[error("invalid configuration: {0}")]
InvalidConfig(String),
#[error("invalid MPEG-TS packet: {0}")]
InvalidTransportStream(&'static str),
}
pub type Result<T> = std::result::Result<T, CoreError>;

View File

@@ -0,0 +1,319 @@
// 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 bytes::Bytes;
use crate::ts::{PACKET_SIZE, Packet, PacketFramer, crc32_mpeg2};
const PID_PAT: u16 = 0x0000;
const PID_CAT: u16 = 0x0001;
const PID_NIT: u16 = 0x0010;
const PID_SDT: u16 = 0x0011;
const PID_EIT: u16 = 0x0012;
const PID_RST: u16 = 0x0013;
const PID_TOT: u16 = 0x0014;
#[derive(Debug)]
pub struct ServiceFilter {
service_id: u16,
framer: PacketFramer,
assemblers: HashMap<u16, SectionAssembler>,
pmt_pid: Option<u16>,
allowed_pids: HashSet<u16>,
pat_counter: u8,
}
impl ServiceFilter {
#[must_use]
pub fn new(service_id: u16) -> Self {
Self {
service_id,
framer: PacketFramer::default(),
assemblers: HashMap::new(),
pmt_pid: None,
allowed_pids: HashSet::from([PID_CAT, PID_NIT, PID_SDT, PID_EIT, PID_RST, PID_TOT]),
pat_counter: 0,
}
}
#[must_use]
pub fn push(&mut self, chunk: &[u8]) -> Bytes {
let mut packets = Vec::new();
self.framer.push(chunk, |packet| packets.push(*packet));
let mut output = Vec::with_capacity(packets.len() * PACKET_SIZE);
for packet_bytes in packets {
let Ok(packet) = Packet::new(&packet_bytes) else {
continue;
};
let pid = packet.pid();
if pid == PID_PAT {
for section in self.assemblers.entry(pid).or_default().push(packet) {
if let Some(program) = parse_pat(&section, self.service_id) {
self.pmt_pid = Some(program.pmt_pid);
self.allowed_pids.insert(program.pmt_pid);
output.extend_from_slice(&build_pat_packet(
&section,
program,
self.pat_counter,
));
self.pat_counter = self.pat_counter.wrapping_add(1) & 0x0f;
}
}
continue;
}
if Some(pid) == self.pmt_pid {
for section in self.assemblers.entry(pid).or_default().push(packet) {
if let Some(program_pids) = parse_pmt(&section, self.service_id) {
self.allowed_pids.extend(program_pids);
}
}
}
if self.allowed_pids.contains(&pid) {
output.extend_from_slice(&packet_bytes);
}
}
Bytes::from(output)
}
}
#[derive(Debug, Clone, Copy)]
struct PatProgram {
program_number: u16,
pmt_pid: u16,
}
#[derive(Debug, Default)]
pub(crate) struct SectionAssembler {
buffer: Vec<u8>,
expected: Option<usize>,
}
impl SectionAssembler {
pub(crate) fn push(&mut self, packet: Packet<'_>) -> Vec<Vec<u8>> {
let Some(payload) = packet.payload() else {
return Vec::new();
};
if payload.is_empty() {
return Vec::new();
}
let mut completed = Vec::new();
if packet.payload_unit_start() {
let pointer = usize::from(payload[0]);
let pointer_end = 1usize.saturating_add(pointer).min(payload.len());
if !self.buffer.is_empty() && pointer_end > 1 {
self.consume(&payload[1..pointer_end], &mut completed);
}
self.buffer.clear();
self.expected = None;
self.consume(&payload[pointer_end..], &mut completed);
} else {
self.consume(payload, &mut completed);
}
completed
}
fn consume(&mut self, mut data: &[u8], completed: &mut Vec<Vec<u8>>) {
while !data.is_empty() {
if self.buffer.is_empty() && data[0] == 0xff {
return;
}
if self.expected.is_none() && self.buffer.len() < 3 {
let needed = 3 - self.buffer.len();
let taken = needed.min(data.len());
self.buffer.extend_from_slice(&data[..taken]);
data = &data[taken..];
if self.buffer.len() == 3 {
let section_length =
(usize::from(self.buffer[1] & 0x0f) << 8) | usize::from(self.buffer[2]);
self.expected = Some(3 + section_length);
}
continue;
}
let Some(expected) = self.expected else {
continue;
};
let needed = expected.saturating_sub(self.buffer.len());
let taken = needed.min(data.len());
self.buffer.extend_from_slice(&data[..taken]);
data = &data[taken..];
if self.buffer.len() == expected {
completed.push(std::mem::take(&mut self.buffer));
self.expected = None;
}
}
}
}
fn parse_pat(section: &[u8], service_id: u16) -> Option<PatProgram> {
if section.first().copied() != Some(0x00) || section.len() < 12 {
return None;
}
let end = section.len().checked_sub(4)?;
let mut offset = 8;
while offset + 4 <= end {
let program_number = u16::from_be_bytes([section[offset], section[offset + 1]]);
let pmt_pid = (u16::from(section[offset + 2] & 0x1f) << 8) | u16::from(section[offset + 3]);
if program_number == service_id {
return Some(PatProgram {
program_number,
pmt_pid,
});
}
offset += 4;
}
None
}
fn parse_pmt(section: &[u8], service_id: u16) -> Option<HashSet<u16>> {
if section.first().copied() != Some(0x02) || section.len() < 16 {
return None;
}
if u16::from_be_bytes([section[3], section[4]]) != service_id {
return None;
}
let end = section.len().checked_sub(4)?;
let mut pids = HashSet::new();
pids.insert((u16::from(section[8] & 0x1f) << 8) | u16::from(section[9]));
let program_info_length = (usize::from(section[10] & 0x0f) << 8) | usize::from(section[11]);
let program_info_end = 12usize.checked_add(program_info_length)?;
if program_info_end > end {
return None;
}
collect_ca_pids(&section[12..program_info_end], &mut pids);
let mut offset = program_info_end;
while offset + 5 <= end {
let elementary_pid =
(u16::from(section[offset + 1] & 0x1f) << 8) | u16::from(section[offset + 2]);
let info_length =
(usize::from(section[offset + 3] & 0x0f) << 8) | usize::from(section[offset + 4]);
let info_end = offset.checked_add(5)?.checked_add(info_length)?;
if info_end > end {
return None;
}
pids.insert(elementary_pid);
collect_ca_pids(&section[offset + 5..info_end], &mut pids);
offset = info_end;
}
Some(pids)
}
fn collect_ca_pids(descriptors: &[u8], pids: &mut HashSet<u16>) {
let mut offset = 0;
while offset + 2 <= descriptors.len() {
let length = usize::from(descriptors[offset + 1]);
let end = offset.saturating_add(2).saturating_add(length);
if end > descriptors.len() {
return;
}
if descriptors[offset] == 0x09 && length >= 4 {
pids.insert(
(u16::from(descriptors[offset + 4] & 0x1f) << 8)
| u16::from(descriptors[offset + 5]),
);
}
offset = end;
}
}
fn build_pat_packet(original: &[u8], program: PatProgram, continuity: u8) -> [u8; PACKET_SIZE] {
let transport_stream_id = original.get(3..5).unwrap_or(&[0, 0]);
let version = original.get(5).copied().unwrap_or(0xc1);
let mut section = vec![
0x00,
0xb0,
0x0d,
transport_stream_id[0],
transport_stream_id[1],
version,
0x00,
0x00,
(program.program_number >> 8) as u8,
u8::try_from(program.program_number & 0xff).unwrap_or(0),
0xe0 | u8::try_from(program.pmt_pid >> 8).unwrap_or(0),
u8::try_from(program.pmt_pid & 0xff).unwrap_or(0),
];
section.extend_from_slice(&crc32_mpeg2(&section).to_be_bytes());
let mut packet = [0xff; PACKET_SIZE];
packet[0] = 0x47;
packet[1] = 0x40;
packet[2] = 0x00;
packet[3] = 0x10 | (continuity & 0x0f);
packet[4] = 0x00;
packet[5..5 + section.len()].copy_from_slice(&section);
packet
}
#[cfg(test)]
mod tests {
use crate::ts::{PACKET_SIZE, crc32_mpeg2};
use super::ServiceFilter;
#[test]
fn retains_only_selected_program_pids_and_rewrites_pat() {
let association_packet = psi_packet(
0,
&section_with_crc(vec![
0x00, 0xb0, 0x11, 0x00, 0x01, 0xc1, 0x00, 0x00, 0x00, 0x65, 0xe1, 0x00, 0x00, 0x66,
0xe2, 0x00,
]),
);
let program_map_packet = psi_packet(
0x0100,
&section_with_crc(vec![
0x02, 0xb0, 0x12, 0x00, 0x65, 0xc1, 0x00, 0x00, 0xe1, 0x01, 0xf0, 0x00, 0x1b, 0xe1,
0x01, 0xf0, 0x00,
]),
);
let video = payload_packet(0x0101);
let other = payload_packet(0x0201);
let mut input = Vec::new();
input.extend_from_slice(&association_packet);
input.extend_from_slice(&program_map_packet);
input.extend_from_slice(&video);
input.extend_from_slice(&other);
let output = ServiceFilter::new(101).push(&input);
assert_eq!(output.len(), PACKET_SIZE * 3);
assert_eq!(&output[0..3], &[0x47, 0x40, 0x00]);
assert_eq!(&output[PACKET_SIZE..PACKET_SIZE + 3], &[0x47, 0x41, 0x00]);
assert_eq!(
&output[PACKET_SIZE * 2..PACKET_SIZE * 2 + 3],
&[0x47, 0x01, 0x01]
);
}
fn section_with_crc(mut section: Vec<u8>) -> Vec<u8> {
let crc = crc32_mpeg2(&section);
section.extend_from_slice(&crc.to_be_bytes());
section
}
fn psi_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).unwrap_or(0);
packet[2] = u8::try_from(pid & 0xff).unwrap_or(0);
packet[3] = 0x10;
packet[4] = 0;
packet[5..5 + section.len()].copy_from_slice(section);
packet
}
fn payload_packet(pid: u16) -> [u8; PACKET_SIZE] {
let mut packet = [0xff; PACKET_SIZE];
packet[0] = 0x47;
packet[1] = u8::try_from(pid >> 8).unwrap_or(0);
packet[2] = u8::try_from(pid & 0xff).unwrap_or(0);
packet[3] = 0x10;
packet
}
}

View File

@@ -0,0 +1,15 @@
// 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.
pub mod config;
pub mod epg;
pub mod error;
pub mod filter;
pub mod persistence;
pub mod service;
pub mod ts;
pub mod tuner;
pub use error::{CoreError, Result};

View File

@@ -0,0 +1,241 @@
// 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::{
io::Write,
path::{Path, PathBuf},
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use mirakurun_types::ConfigChannel;
use serde::{Serialize, de::DeserializeOwned};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use crate::{Result, error::CoreError};
/// Loads a Mirakurun JSON database after checking its optional integrity sentinel.
///
/// Missing, syntactically broken, and integrity-mismatched databases are treated
/// as empty for compatibility with the Node.js implementation.
///
/// # Errors
///
/// Returns an error for filesystem failures other than a missing file, or when
/// an individual stored item cannot be deserialized.
pub async fn load_json_db<T>(path: &Path, expected_integrity: &str) -> Result<Vec<T>>
where
T: DeserializeOwned,
{
let contents = match tokio::fs::read(path).await {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(source) => {
return Err(CoreError::Read {
kind: "database",
path: path.to_path_buf(),
source,
});
}
};
let mut values: Vec<Value> = match serde_json::from_slice(&contents) {
Ok(values) => values,
Err(error) => {
tracing::warn!(path = %path.display(), %error, "broken database; starting empty");
return Ok(Vec::new());
}
};
if let Some(integrity) = values.first().and_then(integrity_value) {
if integrity != expected_integrity {
tracing::warn!(
path = %path.display(),
expected = expected_integrity,
actual = integrity,
"database integrity check failed; starting empty"
);
return Ok(Vec::new());
}
values.remove(0);
}
values
.into_iter()
.map(|value| {
serde_json::from_value(value).map_err(|source| CoreError::Json {
path: path.to_path_buf(),
source,
})
})
.collect()
}
/// Saves a Mirakurun JSON database with an integrity sentinel.
///
/// # Errors
///
/// Returns an error when serialization or the atomic filesystem update fails.
pub async fn save_json_db<T>(path: &Path, values: &[T], integrity: &str) -> Result<()>
where
T: Serialize,
{
let mut output = Vec::with_capacity(values.len() + 1);
let mut sentinel = Map::new();
sentinel.insert("__integrity__".into(), Value::String(integrity.into()));
output.push(Value::Object(sentinel));
output.extend(
values
.iter()
.map(serde_json::to_value)
.collect::<std::result::Result<Vec<_>, _>>()?,
);
let bytes = serde_json::to_vec(&output)?;
ensure_parent(path, "database").await?;
atomic_write(path, &bytes, "database").await
}
/// Calculates the base64 channel-config SHA-256 used by database integrity sentinels.
///
/// # Errors
///
/// Returns an error if the channel configuration cannot be serialized.
pub fn channels_integrity(channels: &[ConfigChannel]) -> Result<String> {
let json = serde_json::to_vec(channels)?;
let hash = Sha256::digest(json);
Ok(STANDARD.encode(hash))
}
/// Creates the parent directory for a configured data path.
///
/// # Errors
///
/// Returns an error when the directory cannot be created.
pub async fn ensure_parent(path: &Path, kind: &'static str) -> Result<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|source| CoreError::Write {
kind,
path: parent.to_path_buf(),
source,
})?;
}
Ok(())
}
/// Replaces a file atomically using a temporary file in the same directory.
///
/// # Errors
///
/// Returns an error if the temporary file cannot be written, synchronized, or
/// persisted.
pub async fn atomic_write(path: &Path, contents: &[u8], kind: &'static str) -> Result<()> {
let path = path.to_path_buf();
let bytes = contents.to_vec();
tokio::task::spawn_blocking(move || atomic_write_blocking(&path, &bytes, kind)).await?
}
fn atomic_write_blocking(path: &Path, contents: &[u8], kind: &'static str) -> Result<()> {
let parent = path
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let mut temporary =
tempfile::NamedTempFile::new_in(&parent).map_err(|source| CoreError::Write {
kind,
path: path.to_path_buf(),
source,
})?;
temporary
.write_all(contents)
.and_then(|()| temporary.as_file().sync_all())
.map_err(|source| CoreError::Write {
kind,
path: path.to_path_buf(),
source,
})?;
temporary.persist(path).map_err(|error| CoreError::Write {
kind,
path: path.to_path_buf(),
source: error.error,
})?;
if let Ok(directory) = std::fs::File::open(parent) {
let _ = directory.sync_all();
}
Ok(())
}
fn integrity_value(value: &Value) -> Option<&str> {
value
.as_object()
.and_then(|object| object.get("__integrity__"))
.and_then(Value::as_str)
}
#[cfg(test)]
mod tests {
use mirakurun_types::{ChannelType, ConfigChannel, Service};
use tempfile::tempdir;
use super::{channels_integrity, load_json_db, save_json_db};
#[tokio::test]
async fn database_round_trip_and_integrity_check() {
let directory = tempdir().expect("create tempdir");
let path = directory.path().join("services.json");
let services = vec![Service {
id: 100_002,
service_id: 2,
network_id: 1,
name: "service".into(),
service_type: 1,
logo_id: None,
has_logo_data: None,
remote_control_key_id: None,
epg_ready: None,
epg_updated_at: None,
channel: None,
}];
save_json_db(&path, &services, "expected")
.await
.expect("save database");
let loaded: Vec<Service> = load_json_db(&path, "expected")
.await
.expect("load database");
assert_eq!(loaded, services);
let rejected: Vec<Service> = load_json_db(&path, "different")
.await
.expect("load with mismatched integrity");
assert!(rejected.is_empty());
}
#[test]
fn channel_integrity_is_sha256_of_compact_json() {
let channels = vec![ConfigChannel {
name: "test".into(),
channel_type: ChannelType::Gr,
channel: "27".into(),
service_id: None,
tsmf_rel_ts: None,
command_vars: None,
is_disabled: None,
satelite: None,
satellite: None,
space: None,
freq: None,
polarity: None,
}];
let integrity = channels_integrity(&channels).expect("calculate integrity");
assert_eq!(integrity.len(), 44);
}
#[test]
fn empty_channel_integrity_matches_node_crypto() {
let integrity = channels_integrity(&[]).expect("calculate integrity");
assert_eq!(integrity, "T1PNoYwrqgwDVLtfmj7L5e0Sq02OEbqHPC8RFhICuUU=");
}
}

View File

@@ -0,0 +1,242 @@
// 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 mirakurun_types::{Channel, ConfigChannel, Service, service_item_id};
use crate::{
epg::decode_arib,
filter::SectionAssembler,
ts::{Packet, PacketFramer, crc32_mpeg2},
};
const PID_SDT: u16 = 0x0011;
const TABLE_ID_SDT_ACTUAL: u8 = 0x42;
#[derive(Debug)]
pub struct ServiceCollector {
channel: ConfigChannel,
framer: PacketFramer,
assembler: SectionAssembler,
services: HashMap<u64, Service>,
sections_seen: HashSet<(u8, u8)>,
last_section: Option<u8>,
packet_count: u64,
}
impl ServiceCollector {
#[must_use]
pub fn new(channel: ConfigChannel) -> Self {
Self {
channel,
framer: PacketFramer::default(),
assembler: SectionAssembler::default(),
services: HashMap::new(),
sections_seen: HashSet::new(),
last_section: None,
packet_count: 0,
}
}
pub fn push(&mut self, chunk: &[u8]) {
let mut packets = Vec::new();
self.framer.push(chunk, |packet| packets.push(*packet));
for packet_bytes in packets {
self.packet_count = self.packet_count.saturating_add(1);
let Ok(packet) = Packet::new(&packet_bytes) else {
continue;
};
if packet.pid() != PID_SDT {
continue;
}
for section in self.assembler.push(packet) {
let Some(parsed) = parse_sdt_section(&section, &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)
})
})
}
#[must_use]
pub const fn packet_count(&self) -> u64 {
self.packet_count
}
#[must_use]
pub fn into_services(self) -> Vec<Service> {
let mut services = self.services.into_values().collect::<Vec<_>>();
services.sort_by_key(|service| service.service_id);
services
}
}
struct ParsedSdt {
version: u8,
section_number: u8,
last_section: u8,
services: Vec<Service>,
}
fn parse_sdt_section(section: &[u8], channel: &ConfigChannel) -> Option<ParsedSdt> {
if section.len() < 15
|| section[0] != TABLE_ID_SDT_ACTUAL
|| section[5] & 0x01 == 0
|| crc32_mpeg2(section) != 0
{
return None;
}
let network_id = u16::from_be_bytes([section[8], section[9]]);
let end = section.len().checked_sub(4)?;
let mut services = Vec::new();
let mut offset = 11;
while offset + 5 <= end {
let service_id = u16::from_be_bytes([section[offset], section[offset + 1]]);
let descriptors_length =
(usize::from(section[offset + 3] & 0x0f) << 8) | usize::from(section[offset + 4]);
let descriptor_start = offset.checked_add(5)?;
let descriptor_end = descriptor_start.checked_add(descriptors_length)?;
if descriptor_end > end {
return None;
}
if channel
.service_id
.is_none_or(|configured| configured == service_id)
{
if let Some((service_type, name)) =
parse_service_descriptor(&section[descriptor_start..descriptor_end])
{
services.push(Service {
id: service_item_id(network_id, service_id),
service_id,
network_id,
name,
service_type,
logo_id: None,
has_logo_data: None,
remote_control_key_id: None,
epg_ready: None,
epg_updated_at: None,
channel: Some(Box::new(Channel {
channel_type: channel.channel_type,
channel: channel.channel.clone(),
name: Some(channel.name.clone()),
services: None,
})),
});
}
}
offset = descriptor_end;
}
Some(ParsedSdt {
version: (section[5] >> 1) & 0x1f,
section_number: section[6],
last_section: section[7],
services,
})
}
fn parse_service_descriptor(mut descriptors: &[u8]) -> Option<(u8, String)> {
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 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;
}
return Some((body[0], decode_arib(&body[name_start..name_end])));
}
descriptors = &descriptors[end..];
}
None
}
#[cfg(test)]
mod tests {
use mirakurun_types::{ChannelType, ConfigChannel};
use crate::ts::{PACKET_SIZE, crc32_mpeg2};
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);
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(&section);
section.extend_from_slice(&crc.to_be_bytes());
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(&section);
let mut collector = ServiceCollector::new(ConfigChannel {
name: "channel".into(),
channel_type: ChannelType::Gr,
channel: "27".into(),
service_id: None,
tsmf_rel_ts: None,
command_vars: None,
is_disabled: None,
satelite: None,
satellite: None,
space: None,
freq: None,
polarity: None,
});
collector.push(&packet);
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);
}
}

View File

@@ -0,0 +1,220 @@
// 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 crate::{Result, error::CoreError};
pub const PACKET_SIZE: usize = 188;
pub const SYNC_BYTE: u8 = 0x47;
#[derive(Debug, Clone, Copy)]
pub struct Packet<'a> {
bytes: &'a [u8; PACKET_SIZE],
}
impl<'a> Packet<'a> {
/// Creates a view over one 188-byte transport-stream packet.
///
/// # Errors
///
/// Returns an error if the MPEG-TS sync byte is missing.
pub fn new(bytes: &'a [u8; PACKET_SIZE]) -> Result<Self> {
if bytes[0] != SYNC_BYTE {
return Err(CoreError::InvalidTransportStream("missing sync byte"));
}
Ok(Self { bytes })
}
#[must_use]
pub fn bytes(self) -> &'a [u8; PACKET_SIZE] {
self.bytes
}
#[must_use]
pub fn transport_error(self) -> bool {
self.bytes[1] & 0x80 != 0
}
#[must_use]
pub fn payload_unit_start(self) -> bool {
self.bytes[1] & 0x40 != 0
}
#[must_use]
pub fn pid(self) -> u16 {
(u16::from(self.bytes[1] & 0x1f) << 8) | u16::from(self.bytes[2])
}
#[must_use]
pub fn has_payload(self) -> bool {
self.bytes[3] & 0x10 != 0
}
#[must_use]
pub fn has_adaptation_field(self) -> bool {
self.bytes[3] & 0x20 != 0
}
#[must_use]
pub fn continuity_counter(self) -> u8 {
self.bytes[3] & 0x0f
}
#[must_use]
pub fn payload(self) -> Option<&'a [u8]> {
if !self.has_payload() {
return None;
}
let offset = if self.has_adaptation_field() {
5usize.saturating_add(usize::from(self.bytes[4]))
} else {
4
};
(offset <= PACKET_SIZE).then(|| &self.bytes[offset..])
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PidStats {
pub packets: u64,
pub drops: u64,
}
#[derive(Debug, Default)]
pub struct ContinuityTracker {
entries: HashMap<u16, (u8, PidStats)>,
}
impl ContinuityTracker {
pub fn observe(&mut self, packet: Packet<'_>) {
let pid = packet.pid();
if pid == 0x1fff {
return;
}
let counter = packet.continuity_counter();
let entry = self.entries.entry(pid).or_default();
if packet.has_payload() && entry.1.packets > 0 {
let expected = entry.0.wrapping_add(1) & 0x0f;
if counter != expected {
entry.1.drops += u64::from(counter.wrapping_sub(expected) & 0x0f);
}
}
entry.0 = counter;
entry.1.packets += 1;
}
#[must_use]
pub fn stats(&self) -> HashMap<u16, PidStats> {
self.entries
.iter()
.map(|(&pid, &(_, stats))| (pid, stats))
.collect()
}
}
#[derive(Debug, Default)]
pub struct PacketFramer {
buffer: Vec<u8>,
}
impl PacketFramer {
pub fn push<F>(&mut self, chunk: &[u8], mut emit: F)
where
F: FnMut(&[u8; PACKET_SIZE]),
{
self.buffer.extend_from_slice(chunk);
loop {
let Some(sync_position) = find_sync(&self.buffer) else {
let keep = self.buffer.len().min(PACKET_SIZE - 1);
self.buffer.drain(..self.buffer.len() - keep);
break;
};
if sync_position > 0 {
self.buffer.drain(..sync_position);
}
if self.buffer.len() < PACKET_SIZE {
break;
}
let mut packet = [0_u8; PACKET_SIZE];
packet.copy_from_slice(&self.buffer[..PACKET_SIZE]);
emit(&packet);
self.buffer.drain(..PACKET_SIZE);
}
}
}
#[must_use]
pub fn crc32_mpeg2(bytes: &[u8]) -> u32 {
let mut crc = 0xffff_ffffu32;
for &byte in bytes {
crc ^= u32::from(byte) << 24;
for _ in 0..8 {
crc = if crc & 0x8000_0000 != 0 {
(crc << 1) ^ 0x04c1_1db7
} else {
crc << 1
};
}
}
crc
}
fn find_sync(buffer: &[u8]) -> Option<usize> {
buffer.iter().enumerate().find_map(|(index, byte)| {
if *byte != SYNC_BYTE {
return None;
}
if index + PACKET_SIZE >= buffer.len() || buffer[index + PACKET_SIZE] == SYNC_BYTE {
Some(index)
} else {
None
}
})
}
#[cfg(test)]
mod tests {
use super::{ContinuityTracker, PACKET_SIZE, Packet, PacketFramer, crc32_mpeg2};
fn packet(pid: u16, counter: u8) -> [u8; PACKET_SIZE] {
let mut packet = [0xff; PACKET_SIZE];
packet[0] = 0x47;
packet[1] = (pid >> 8) as u8 & 0x1f;
packet[2] = u8::try_from(pid & 0xff).unwrap_or(0);
packet[3] = 0x10 | (counter & 0x0f);
packet
}
#[test]
fn frames_unaligned_packets() {
let first = packet(0, 0);
let second = packet(0, 1);
let mut bytes = vec![0, 1, 2];
bytes.extend_from_slice(&first);
bytes.extend_from_slice(&second);
let mut count = 0;
PacketFramer::default().push(&bytes, |_| count += 1);
assert_eq!(count, 2);
}
#[test]
fn detects_continuity_drops() {
let mut tracker = ContinuityTracker::default();
let first = packet(256, 0);
let second = packet(256, 2);
tracker.observe(Packet::new(&first).expect("valid packet"));
tracker.observe(Packet::new(&second).expect("valid packet"));
assert_eq!(tracker.stats()[&256].drops, 1);
}
#[test]
fn mpeg_crc_matches_known_pat_section() {
let section = [
0x00, 0xb0, 0x0d, 0x00, 0x01, 0xc1, 0x00, 0x00, 0x00, 0x01, 0xe1, 0x00,
];
assert_eq!(crc32_mpeg2(&section), 0xe8f9_5e7d);
}
}

View File

@@ -0,0 +1,846 @@
// 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,
process::Stdio,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
time::Duration,
};
use bytes::Bytes;
use mirakurun_types::{
ChannelType, CommandVariable, ConfigChannel, ConfigTuner, StreamSetting, TunerDevice, TunerUser,
};
use nix::{
sys::signal::{Signal, kill},
unistd::Pid,
};
use tokio::{
io::{AsyncRead, AsyncReadExt},
process::{Child, ChildStdout, Command},
sync::{broadcast, mpsc, oneshot},
};
use crate::{CoreError, Result};
const STREAM_CHANNEL_CAPACITY: usize = 512;
const DEVICE_COMMAND_CAPACITY: usize = 64;
const IDLE_TIMEOUT: Duration = Duration::from_secs(3);
const TERMINATE_TIMEOUT: Duration = Duration::from_secs(6);
static NEXT_SUBSCRIPTION_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Clone)]
pub struct StreamRequest {
pub channel: ConfigChannel,
pub service_id: Option<u16>,
pub event_id: Option<u16>,
pub priority: i32,
pub agent: Option<String>,
pub url: Option<String>,
pub disable_decoder: bool,
}
#[derive(Debug)]
pub struct TunerSubscription {
pub device_index: usize,
pub receiver: broadcast::Receiver<Bytes>,
pub decoder: Option<String>,
pub user_id: String,
command: mpsc::Sender<DeviceCommand>,
subscription_id: u64,
}
impl Drop for TunerSubscription {
fn drop(&mut self) {
let _ = self.command.try_send(DeviceCommand::Unsubscribe {
subscription_id: self.subscription_id,
});
}
}
#[derive(Debug, Clone)]
pub struct TunerManager {
devices: Arc<Vec<DeviceHandle>>,
}
impl TunerManager {
#[must_use]
pub fn new(configs: &[ConfigTuner]) -> Self {
let devices = configs
.iter()
.filter(|config| config.is_disabled != Some(true))
.cloned()
.enumerate()
.map(|(index, config)| DeviceHandle::spawn(index, config))
.collect();
Self {
devices: Arc::new(devices),
}
}
/// Subscribes to a compatible tuner, sharing an existing multiplex when possible.
///
/// # Errors
///
/// Returns an error when no tuner can satisfy the channel and priority, or
/// when the tuner process cannot be started.
pub async fn subscribe(&self, request: StreamRequest) -> Result<TunerSubscription> {
let mut candidates = self.candidates(&request.channel, request.priority).await;
while let Some(index) = candidates.first().copied() {
candidates.remove(0);
match self.devices[index].subscribe(request.clone()).await {
Ok(subscription) => return Ok(subscription),
Err(CoreError::InvalidConfig(_)) => continue,
Err(error) => return Err(error),
}
}
Err(CoreError::InvalidConfig("no available tuners".into()))
}
pub async fn statuses(&self) -> Vec<TunerDevice> {
let mut statuses = Vec::with_capacity(self.devices.len());
for device in self.devices.iter() {
if let Some(status) = device.status().await {
statuses.push(status);
}
}
statuses
}
/// Terminates the process owned by a tuner index.
///
/// # Errors
///
/// Returns an error if the index does not exist or the device task stopped.
pub async fn kill(&self, index: usize) -> Result<()> {
let Some(device) = self.devices.get(index) else {
return Err(CoreError::InvalidConfig(format!(
"tuner index {index} does not exist"
)));
};
device.kill().await
}
async fn candidates(&self, channel: &ConfigChannel, priority: i32) -> Vec<usize> {
let mut joined = Vec::new();
let mut free = Vec::new();
let mut idle = Vec::new();
let mut takeover = Vec::new();
for device in self.devices.iter() {
let Some(snapshot) = device.snapshot().await else {
continue;
};
if !snapshot.types.contains(&channel.channel_type) || snapshot.fault {
continue;
}
if snapshot.channel.as_ref() == Some(channel) && snapshot.available {
joined.push((snapshot.index, snapshot.priority));
} else if snapshot.channel.is_none() && snapshot.users == 0 && snapshot.available {
free.push((snapshot.index, snapshot.priority));
} else if snapshot.users == 0 && snapshot.available {
idle.push((snapshot.index, snapshot.priority));
} else if priority >= 0
&& snapshot.available
&& snapshot.users > 0
&& snapshot.priority < priority
{
takeover.push((snapshot.index, snapshot.priority));
}
}
takeover.sort_by_key(|(_, current_priority)| *current_priority);
joined
.into_iter()
.chain(free)
.chain(idle)
.chain(takeover)
.map(|(index, _)| index)
.collect()
}
}
#[derive(Debug, Clone)]
struct DeviceHandle {
command: mpsc::Sender<DeviceCommand>,
}
impl DeviceHandle {
fn spawn(index: usize, config: ConfigTuner) -> Self {
let (command, receiver) = mpsc::channel(DEVICE_COMMAND_CAPACITY);
let actor_command = command.clone();
tokio::spawn(async move {
DeviceActor::new(index, config, actor_command)
.run(receiver)
.await;
});
Self { command }
}
async fn subscribe(&self, request: StreamRequest) -> Result<TunerSubscription> {
let (response, result) = oneshot::channel();
self.command
.send(DeviceCommand::Subscribe { request, response })
.await
.map_err(|_| CoreError::InvalidConfig("tuner task stopped".into()))?;
result
.await
.map_err(|_| CoreError::InvalidConfig("tuner task stopped".into()))?
}
async fn status(&self) -> Option<TunerDevice> {
let (response, result) = oneshot::channel();
self.command
.send(DeviceCommand::Status { response })
.await
.ok()?;
result.await.ok()
}
async fn snapshot(&self) -> Option<DeviceSnapshot> {
let (response, result) = oneshot::channel();
self.command
.send(DeviceCommand::Snapshot { response })
.await
.ok()?;
result.await.ok()
}
async fn kill(&self) -> Result<()> {
let (response, result) = oneshot::channel();
self.command
.send(DeviceCommand::Kill { response })
.await
.map_err(|_| CoreError::InvalidConfig("tuner task stopped".into()))?;
result
.await
.map_err(|_| CoreError::InvalidConfig("tuner task stopped".into()))?
}
}
#[derive(Debug)]
enum DeviceCommand {
Subscribe {
request: StreamRequest,
response: oneshot::Sender<Result<TunerSubscription>>,
},
Unsubscribe {
subscription_id: u64,
},
StopIfIdle {
generation: u64,
},
OutputEnded {
generation: u64,
},
Status {
response: oneshot::Sender<TunerDevice>,
},
Snapshot {
response: oneshot::Sender<DeviceSnapshot>,
},
Kill {
response: oneshot::Sender<Result<()>>,
},
}
#[derive(Debug)]
struct DeviceActor {
index: usize,
config: ConfigTuner,
command_sender: mpsc::Sender<DeviceCommand>,
current_channel: Option<ConfigChannel>,
command_display: Option<String>,
process: Option<ProcessGroup>,
broadcaster: Option<broadcast::Sender<Bytes>>,
users: HashMap<u64, TunerUser>,
available: bool,
fault: bool,
fatal_count: u8,
generation: u64,
}
impl DeviceActor {
fn new(index: usize, config: ConfigTuner, command_sender: mpsc::Sender<DeviceCommand>) -> Self {
Self {
index,
config,
command_sender,
current_channel: None,
command_display: None,
process: None,
broadcaster: None,
users: HashMap::new(),
available: true,
fault: false,
fatal_count: 0,
generation: 0,
}
}
async fn run(mut self, mut commands: mpsc::Receiver<DeviceCommand>) {
while let Some(command) = commands.recv().await {
match command {
DeviceCommand::Subscribe { request, response } => {
let result = self.subscribe(request).await;
let _ = response.send(result);
}
DeviceCommand::Unsubscribe { subscription_id } => {
self.unsubscribe(subscription_id);
}
DeviceCommand::StopIfIdle { generation } => {
if generation == self.generation && self.users.is_empty() {
if let Err(error) = self.stop_process().await {
tracing::warn!(device = self.index, %error, "failed to stop idle tuner");
}
}
}
DeviceCommand::OutputEnded { generation } => {
if generation == self.generation {
self.output_ended().await;
}
}
DeviceCommand::Status { response } => {
let _ = response.send(self.status());
}
DeviceCommand::Snapshot { response } => {
let _ = response.send(self.snapshot());
}
DeviceCommand::Kill { response } => {
self.users.clear();
self.broadcaster = None;
let result = self.stop_process().await;
let _ = response.send(result);
}
}
}
let _ = self.stop_process().await;
}
async fn subscribe(&mut self, request: StreamRequest) -> Result<TunerSubscription> {
if self.fault || !self.config.types.contains(&request.channel.channel_type) {
return Err(CoreError::InvalidConfig(
"tuner is not available for this channel".into(),
));
}
if self.current_channel.as_ref() != Some(&request.channel) {
let current_priority = self
.users
.values()
.map(|user| user.priority)
.max()
.unwrap_or(-2);
if !self.users.is_empty() && request.priority <= current_priority {
return Err(CoreError::InvalidConfig(
"tuner has a higher priority user".into(),
));
}
self.users.clear();
self.broadcaster = None;
self.stop_process().await?;
self.start_process(request.channel.clone()).await?;
} else if self.process.is_none() {
self.start_process(request.channel.clone()).await?;
}
let broadcaster = self
.broadcaster
.as_ref()
.ok_or_else(|| CoreError::InvalidConfig("tuner stream is unavailable".into()))?;
let subscription_id = NEXT_SUBSCRIPTION_ID.fetch_add(1, Ordering::Relaxed);
let user_id = subscription_id.to_string();
let decoder = if request.disable_decoder
|| (self.config.remote_mirakurun_host.is_some()
&& self.config.remote_mirakurun_decoder == Some(true))
{
None
} else {
self.config.decoder.clone()
};
self.users.insert(
subscription_id,
TunerUser {
id: user_id.clone(),
priority: request.priority,
agent: request.agent,
url: request.url,
disable_decoder: request.disable_decoder.then_some(true),
stream_setting: Some(StreamSetting {
channel: request.channel,
network_id: None,
service_id: request.service_id,
event_id: request.event_id,
no_provide: None,
parse_nit: None,
parse_sdt: None,
parse_eit: None,
}),
stream_info: None,
},
);
Ok(TunerSubscription {
device_index: self.index,
receiver: broadcaster.subscribe(),
decoder,
user_id,
command: self.command_sender.clone(),
subscription_id,
})
}
fn unsubscribe(&mut self, subscription_id: u64) {
if self.users.remove(&subscription_id).is_none() || !self.users.is_empty() {
return;
}
let generation = self.generation;
let command = self.command_sender.clone();
tokio::spawn(async move {
tokio::time::sleep(IDLE_TIMEOUT).await;
let _ = command.send(DeviceCommand::StopIfIdle { generation }).await;
});
}
async fn start_process(&mut self, channel: ConfigChannel) -> Result<()> {
self.generation = self.generation.wrapping_add(1);
let generation = self.generation;
let broadcaster = self.broadcaster.clone().unwrap_or_else(|| {
let (broadcaster, _) = broadcast::channel(STREAM_CHANNEL_CAPACITY);
broadcaster
});
let (process, output, display) = spawn_tuner(&self.config, &channel).await?;
self.current_channel = Some(channel);
self.command_display = Some(display);
self.process = Some(process);
self.broadcaster = Some(broadcaster.clone());
self.available = true;
let command = self.command_sender.clone();
tokio::spawn(async move {
read_output(output, broadcaster).await;
let _ = command
.send(DeviceCommand::OutputEnded { generation })
.await;
});
Ok(())
}
async fn output_ended(&mut self) {
let was_running = self.process.is_some();
if let Some(mut process) = self.process.take() {
process.wait().await;
}
self.command_display = None;
self.available = false;
if !was_running {
return;
}
if self.users.is_empty() {
self.release();
return;
}
self.fatal_count = self.fatal_count.saturating_add(1);
if self.fatal_count >= 3 {
self.fault = true;
self.users.clear();
self.broadcaster = None;
self.current_channel = None;
return;
}
let Some(channel) = self.current_channel.clone() else {
self.release();
return;
};
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");
self.users.clear();
self.broadcaster = None;
self.fault = true;
}
}
async fn stop_process(&mut self) -> Result<()> {
self.generation = self.generation.wrapping_add(1);
self.available = false;
if let Some(mut process) = self.process.take() {
process.terminate().await;
}
self.release();
Ok(())
}
fn release(&mut self) {
self.command_display = None;
self.current_channel = None;
self.broadcaster = None;
self.fatal_count = 0;
self.available = !self.fault;
}
fn status(&self) -> TunerDevice {
TunerDevice {
index: self.index,
name: self.config.name.clone(),
types: self.config.types.clone(),
command: self.command_display.clone(),
pid: self.process.as_ref().and_then(ProcessGroup::pid),
users: self.users.values().cloned().collect(),
is_available: self.available,
is_remote: self.config.remote_mirakurun_host.is_some(),
is_free: self.available && self.current_channel.is_none() && self.users.is_empty(),
is_using: self.available && self.current_channel.is_some() && !self.users.is_empty(),
is_fault: self.fault,
}
}
fn snapshot(&self) -> DeviceSnapshot {
DeviceSnapshot {
index: self.index,
types: self.config.types.clone(),
channel: self.current_channel.clone(),
users: self.users.len(),
priority: self
.users
.values()
.map(|user| user.priority)
.max()
.unwrap_or(-2),
available: self.available,
fault: self.fault,
}
}
}
#[derive(Debug)]
struct DeviceSnapshot {
index: usize,
types: Vec<ChannelType>,
channel: Option<ConfigChannel>,
users: usize,
priority: i32,
available: bool,
fault: bool,
}
#[derive(Debug)]
struct ProcessGroup {
children: Vec<Child>,
}
impl ProcessGroup {
fn pid(&self) -> Option<u32> {
self.children.first().and_then(Child::id)
}
async fn terminate(&mut self) {
for child in &mut self.children {
if let Some(id) = child.id() {
if let Ok(pid) = i32::try_from(id) {
let _ = kill(Pid::from_raw(pid), Signal::SIGTERM);
}
}
}
let deadline = tokio::time::Instant::now() + TERMINATE_TIMEOUT;
for child in &mut self.children {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if tokio::time::timeout(remaining, child.wait()).await.is_err() {
let _ = child.start_kill();
let _ = child.wait().await;
}
}
}
async fn wait(&mut self) {
for child in &mut self.children {
let _ = child.wait().await;
}
}
}
async fn spawn_tuner(
config: &ConfigTuner,
channel: &ConfigChannel,
) -> Result<(ProcessGroup, TunerOutput, String)> {
if let Some(host) = &config.remote_mirakurun_host {
spawn_remote_tuner(config, channel, host)
.map(|(process, output, display)| (process, TunerOutput::Process(output), display))
} else {
spawn_local_tuner(config, channel).await
}
}
async fn spawn_local_tuner(
config: &ConfigTuner,
channel: &ConfigChannel,
) -> Result<(ProcessGroup, TunerOutput, String)> {
let template = config
.command
.as_deref()
.ok_or_else(|| CoreError::InvalidConfig("tuner command is missing".into()))?;
let display = replace_command_template(template, channel);
let (program, args) = parse_command(&display)?;
let use_dvb_device = config.dvb_device_path.is_some();
let mut command = Command::new(&program);
command
.args(args)
.stdout(if use_dvb_device {
Stdio::null()
} else {
Stdio::piped()
})
.stderr(Stdio::inherit())
.kill_on_drop(true);
let mut tuner = command.spawn().map_err(|source| CoreError::Read {
kind: "tuner process",
path: program.clone().into(),
source,
})?;
if let Some(device_path) = &config.dvb_device_path {
let output =
tokio::fs::File::open(device_path)
.await
.map_err(|source| CoreError::Read {
kind: "DVB device",
path: device_path.into(),
source,
})?;
Ok((
ProcessGroup {
children: vec![tuner],
},
TunerOutput::Dvb(output),
display,
))
} else {
let output = tuner
.stdout
.take()
.ok_or_else(|| CoreError::InvalidConfig("tuner did not expose stdout".into()))?;
Ok((
ProcessGroup {
children: vec![tuner],
},
TunerOutput::Process(output),
display,
))
}
}
fn spawn_remote_tuner(
config: &ConfigTuner,
channel: &ConfigChannel,
host: &str,
) -> Result<(ProcessGroup, ChildStdout, String)> {
let executable = std::env::current_exe().map_err(|source| CoreError::Read {
kind: "current executable",
path: "<current executable>".into(),
source,
})?;
let port = config.remote_mirakurun_port.unwrap_or(40_772);
let mut command = Command::new(&executable);
command
.arg("remote")
.arg(host)
.arg(port.to_string())
.arg(channel.channel_type.as_str())
.arg(&channel.channel);
if config.remote_mirakurun_decoder == Some(true) {
command.arg("--decode");
}
command
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true);
let decode_argument = if config.remote_mirakurun_decoder == Some(true) {
" --decode"
} else {
""
};
let display = format!(
"{} remote {} {} {} {}{}",
executable.display(),
host,
port,
channel.channel_type.as_str(),
channel.channel,
decode_argument
);
let mut child = command.spawn().map_err(|source| CoreError::Read {
kind: "remote tuner process",
path: executable,
source,
})?;
let output = child
.stdout
.take()
.ok_or_else(|| CoreError::InvalidConfig("remote tuner did not expose stdout".into()))?;
Ok((
ProcessGroup {
children: vec![child],
},
output,
display,
))
}
enum TunerOutput {
Process(ChildStdout),
Dvb(tokio::fs::File),
}
async fn read_output(output: TunerOutput, broadcaster: broadcast::Sender<Bytes>) {
match output {
TunerOutput::Process(output) => broadcast_output(output, broadcaster).await,
TunerOutput::Dvb(output) => broadcast_output(output, broadcaster).await,
}
}
async fn broadcast_output<R>(mut output: R, broadcaster: broadcast::Sender<Bytes>)
where
R: AsyncRead + Unpin,
{
let mut buffer = vec![0_u8; 32 * 1024];
loop {
match output.read(&mut buffer).await {
Ok(0) => break,
Ok(length) => {
if broadcaster
.send(Bytes::copy_from_slice(&buffer[..length]))
.is_err()
{
tokio::task::yield_now().await;
}
}
Err(error) => {
tracing::warn!(%error, "failed to read tuner output");
break;
}
}
}
}
fn replace_command_template(template: &str, channel: &ConfigChannel) -> String {
let mut variables: HashMap<&str, String> = HashMap::new();
variables.insert("channel", channel.channel.clone());
variables.insert("type", channel.channel_type.as_str().into());
variables.insert(
"satelite",
channel
.command_vars
.as_ref()
.and_then(|vars| vars.get("satellite"))
.map_or_else(String::new, ToString::to_string),
);
variables.insert("space", "0".into());
if let Some(command_vars) = &channel.command_vars {
for (key, value) in command_vars {
variables.insert(key, command_variable_to_string(value));
}
}
let mut result = String::with_capacity(template.len());
let mut rest = template;
while let Some(start) = rest.find('<') {
result.push_str(&rest[..start]);
let after_start = &rest[start + 1..];
let Some(end) = after_start.find('>') else {
result.push_str(&rest[start..]);
return result;
};
let key = &after_start[..end];
result.push_str(variables.get(key).map_or("", String::as_str));
rest = &after_start[end + 1..];
}
result.push_str(rest);
result
}
fn command_variable_to_string(value: &CommandVariable) -> String {
value.to_string()
}
fn parse_command(command: &str) -> Result<(String, Vec<String>)> {
let mut in_quote = false;
let mut quote = '\0';
let mut current = String::new();
let mut parts = Vec::new();
for character in command.chars() {
if matches!(character, '"' | '\'') && (!in_quote || character == quote) {
in_quote = !in_quote;
quote = if in_quote { character } else { '\0' };
} else if character.is_ascii_whitespace() && !in_quote {
if !current.is_empty() {
parts.push(std::mem::take(&mut current));
}
} else {
current.push(character);
}
}
if in_quote {
return Err(CoreError::InvalidConfig(
"tuner command has an unterminated quote".into(),
));
}
if !current.is_empty() {
parts.push(current);
}
let mut parts = parts.into_iter();
let program = parts
.next()
.ok_or_else(|| CoreError::InvalidConfig("tuner command is empty".into()))?;
Ok((program, parts.collect()))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use mirakurun_types::{ChannelType, CommandVariable, ConfigChannel};
use super::{parse_command, replace_command_template};
#[test]
fn command_parser_preserves_quoted_arguments() {
let (program, args) =
parse_command("command --name 'Tokyo MX' \"two words\"").expect("parse command");
assert_eq!(program, "command");
assert_eq!(args, ["--name", "Tokyo MX", "two words"]);
}
#[test]
fn command_template_uses_channel_and_custom_variables() {
let channel = ConfigChannel {
name: "test".into(),
channel_type: ChannelType::Bs,
channel: "BS01_0".into(),
service_id: None,
tsmf_rel_ts: None,
command_vars: Some(BTreeMap::from([(
"satellite".into(),
CommandVariable::String("JCSAT".into()),
)])),
is_disabled: None,
satelite: None,
satellite: None,
space: None,
freq: None,
polarity: None,
};
assert_eq!(
replace_command_template("rec <type> <channel> <satelite>", &channel),
"rec BS BS01_0 JCSAT"
);
}
}

View File

@@ -0,0 +1,43 @@
[package]
name = "mirakurun-rs"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
build = "build.rs"
[[bin]]
name = "mirakurun-rs"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
async-stream.workspace = true
axum.workspace = true
bytes.workspace = true
chrono.workspace = true
clap.workspace = true
futures-util.workspace = true
http.workspace = true
http-body-util.workspace = true
ipnet.workspace = true
mirakurun-client = { path = "../mirakurun-client" }
mirakurun-core = { path = "../mirakurun-core" }
mirakurun-types = { path = "../mirakurun-types" }
mime_guess.workspace = true
rust-embed.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
tokio-util.workspace = true
tower.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
[dev-dependencies]
tempfile.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,19 @@
// 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::process::Command;
fn main() {
println!("cargo:rerun-if-env-changed=RUSTC");
let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
let version = Command::new(rustc)
.arg("--version")
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.map_or_else(|| "unknown".into(), |version| version.trim().to_owned());
println!("cargo:rustc-env=RUSTC_VERSION={version}");
}

View File

@@ -0,0 +1,177 @@
// 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::{net::SocketAddr, sync::Arc};
use axum::{
extract::{ConnectInfo, State},
http::{
HeaderName, HeaderValue, Request, StatusCode, Uri,
header::{ORIGIN, REFERER, SERVER},
},
middleware::Next,
response::{IntoResponse, Response},
};
use ipnet::IpNet;
use mirakurun_types::ConfigServer;
use crate::state::AppState;
static CROSS_ORIGIN_RESOURCE_POLICY: HeaderName =
HeaderName::from_static("cross-origin-resource-policy");
static CROSS_ORIGIN_EMBEDDER_POLICY: HeaderName =
HeaderName::from_static("cross-origin-embedder-policy");
static X_CONTENT_TYPE_OPTIONS: HeaderName = HeaderName::from_static("x-content-type-options");
static X_YOUR_IP: HeaderName = HeaderName::from_static("x-your-ip");
static ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK: HeaderName =
HeaderName::from_static("access-control-request-private-network");
static ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK: HeaderName =
HeaderName::from_static("access-control-allow-private-network");
static PRIVATE_NETWORK_ACCESS_NAME: HeaderName =
HeaderName::from_static("private-network-access-name");
static PRIVATE_NETWORK_ACCESS_ID: HeaderName = HeaderName::from_static("private-network-access-id");
pub async fn enforce(
State(state): State<Arc<AppState>>,
request: Request<axum::body::Body>,
next: Next,
) -> Response {
let config = state.config.read().await.server.clone();
let remote_address = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|connect| connect.0.ip());
if remote_address.is_some_and(|address| !permitted_ip(address, &config)) {
return StatusCode::FORBIDDEN.into_response();
}
if !permitted_request_headers(request.headers(), &config) {
return StatusCode::FORBIDDEN.into_response();
}
let pna_requested = request
.headers()
.get(&ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK)
.is_some_and(|value| value == "true");
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert(
CROSS_ORIGIN_RESOURCE_POLICY.clone(),
HeaderValue::from_static("cross-origin"),
);
headers.insert(
CROSS_ORIGIN_EMBEDDER_POLICY.clone(),
HeaderValue::from_static("require-corp"),
);
headers.insert(
X_CONTENT_TYPE_OPTIONS.clone(),
HeaderValue::from_static("nosniff"),
);
if let Ok(value) = HeaderValue::from_str(&format!("Mirakurun/{}", env!("CARGO_PKG_VERSION"))) {
headers.insert(SERVER, value);
}
if let Some(address) = remote_address {
if let Ok(value) = HeaderValue::from_str(&address.to_string()) {
headers.insert(X_YOUR_IP.clone(), value);
}
}
if config.allow_pna && pna_requested {
headers.insert(
ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK.clone(),
HeaderValue::from_static("true"),
);
let name = format!(
"Mirakurun_{}",
config.hostname.as_deref().unwrap_or("localhost")
);
if let Ok(value) = HeaderValue::from_str(&name) {
headers.insert(PRIVATE_NETWORK_ACCESS_NAME.clone(), value);
}
headers.insert(
PRIVATE_NETWORK_ACCESS_ID.clone(),
HeaderValue::from_static("00:00:00:00:00"),
);
}
response
}
fn permitted_request_headers(headers: &axum::http::HeaderMap, config: &ConfigServer) -> bool {
if let Some(origin) = headers.get(ORIGIN).and_then(|value| value.to_str().ok()) {
if !permitted_url(origin, config) && !config.allow_origins.iter().any(|item| item == origin)
{
return false;
}
}
if let Some(referer) = headers.get(REFERER).and_then(|value| value.to_str().ok()) {
if !permitted_url(referer, config)
&& !config
.allow_origins
.iter()
.any(|origin| referer.starts_with(origin))
{
return false;
}
}
true
}
fn permitted_url(value: &str, config: &ConfigServer) -> bool {
let Ok(uri) = value.parse::<Uri>() else {
return false;
};
let Some(host) = uri.host() else {
return false;
};
host == "localhost"
|| config.hostname.as_deref() == Some(host)
|| host
.parse()
.is_ok_and(|address| permitted_ip(address, config))
}
fn permitted_ip(address: std::net::IpAddr, config: &ConfigServer) -> bool {
if let std::net::IpAddr::V6(address_v6) = address {
if let Some(address_v4) = address_v6.to_ipv4_mapped() {
return permitted_ip(address_v4.into(), config);
}
}
let ranges = if address.is_ipv4() {
&config.allow_ipv4_cidr_ranges
} else {
&config.allow_ipv6_cidr_ranges
};
ranges.iter().any(|range| {
range
.parse::<IpNet>()
.is_ok_and(|network| network.contains(&address))
})
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr};
use mirakurun_types::ConfigServer;
use super::{permitted_ip, permitted_url};
#[test]
fn permits_default_lan_and_local_origin() {
let config = ConfigServer::default();
assert!(permitted_ip(
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 2)),
&config
));
assert!(!permitted_ip(
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)),
&config
));
assert!(permitted_ip(
"::ffff:127.0.0.1".parse().expect("parse mapped IPv6"),
&config
));
assert!(permitted_url("http://localhost:40772", &config));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
// 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::sync::Arc;
use axum::{
Json,
body::Body,
extract::{OriginalUri, State},
http::{
HeaderValue, StatusCode,
header::{CACHE_CONTROL, CONTENT_TYPE},
},
response::{IntoResponse, Response},
};
use mirakurun_types::ApiError;
use rust_embed::RustEmbed;
use crate::state::AppState;
#[derive(RustEmbed)]
#[folder = "../../web/dist/"]
struct WebAssets;
pub async fn serve(State(state): State<Arc<AppState>>, OriginalUri(uri): OriginalUri) -> Response {
if uri.path().starts_with("/api/") || uri.path() == "/api" {
return not_found();
}
if state.config.read().await.server.disable_web_ui == Some(true) {
return not_found();
}
let requested = uri.path().trim_start_matches('/');
let name = if requested.is_empty() {
"index.html"
} else {
requested
};
let asset = WebAssets::get(name).or_else(|| {
(!name.contains('.'))
.then(|| WebAssets::get("index.html"))
.flatten()
});
let Some(asset) = asset else {
return not_found();
};
let content_type = mime_guess::from_path(name)
.first_or_octet_stream()
.as_ref()
.to_owned();
let cache_control = if name == "index.html" || !name.contains('.') {
"no-cache"
} else {
"public, max-age=31536000, immutable"
};
(
StatusCode::OK,
[
(
CONTENT_TYPE,
HeaderValue::from_str(&content_type)
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
),
(CACHE_CONTROL, HeaderValue::from_static(cache_control)),
],
Body::from(asset.data),
)
.into_response()
}
fn not_found() -> Response {
(
StatusCode::NOT_FOUND,
Json(ApiError {
code: StatusCode::NOT_FOUND.as_u16(),
reason: None,
errors: Vec::new(),
}),
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::WebAssets;
#[test]
fn fallback_index_is_embedded() {
assert!(WebAssets::get("index.html").is_some());
}
}

View File

@@ -0,0 +1,102 @@
// 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::path::PathBuf;
use clap::{Args, Parser, Subcommand};
use mirakurun_core::config::ConfigPaths;
use mirakurun_types::ChannelType;
#[derive(Debug, Parser)]
#[command(
name = "mirakurun-rs",
version,
about = "Mirakurun-compatible DVR tuner server"
)]
pub struct Cli {
#[command(flatten)]
pub paths: PathArguments,
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Debug, Clone, Args)]
pub struct PathArguments {
#[arg(long, env = "SERVER_CONFIG_PATH", global = true)]
pub server_config: Option<PathBuf>,
#[arg(long, env = "TUNERS_CONFIG_PATH", global = true)]
pub tuners_config: Option<PathBuf>,
#[arg(long, env = "CHANNELS_CONFIG_PATH", global = true)]
pub channels_config: Option<PathBuf>,
#[arg(long, env = "SERVICES_DB_PATH", global = true)]
pub services_db: Option<PathBuf>,
#[arg(long, env = "PROGRAMS_DB_PATH", global = true)]
pub programs_db: Option<PathBuf>,
#[arg(long, env = "LOGO_DATA_DIR_PATH", global = true)]
pub logo_dir: Option<PathBuf>,
}
impl PathArguments {
#[must_use]
pub fn resolve(&self) -> ConfigPaths {
let mut paths = ConfigPaths::from_environment();
if let Some(path) = &self.server_config {
paths.server.clone_from(path);
}
if let Some(path) = &self.tuners_config {
paths.tuners.clone_from(path);
}
if let Some(path) = &self.channels_config {
paths.channels.clone_from(path);
}
if let Some(path) = &self.services_db {
paths.services_db.clone_from(path);
}
if let Some(path) = &self.programs_db {
paths.programs_db.clone_from(path);
}
if let Some(path) = &self.logo_dir {
paths.logo_data_dir.clone_from(path);
}
paths
}
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Start the Mirakurun server.
Serve,
/// Extract EPG data from an MPEG-TS input.
Epgdump(EpgdumpArguments),
/// Proxy a stream from another Mirakurun server.
Remote(RemoteArguments),
}
#[derive(Debug, Args)]
pub struct EpgdumpArguments {
/// Overwrite an existing destination file.
#[arg(short = 'f', long)]
pub force: bool,
/// MPEG-TS input path, or `-` for stdin.
pub source: PathBuf,
/// Destination programs.json path.
pub destination: PathBuf,
}
#[derive(Debug, Args)]
pub struct RemoteArguments {
pub host: String,
pub port: u16,
#[arg(value_parser = parse_channel_type)]
pub channel_type: ChannelType,
pub channel: String,
/// Ask the remote Mirakurun server to decode the stream.
#[arg(long)]
pub decode: bool,
}
fn parse_channel_type(value: &str) -> Result<ChannelType, String> {
value.parse()
}

View File

@@ -0,0 +1,147 @@
// 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::path::Path;
use anyhow::{Context, Result, bail};
use futures_util::StreamExt;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
use crate::cli::{EpgdumpArguments, RemoteArguments};
pub async fn remote(arguments: RemoteArguments) -> Result<()> {
let client = mirakurun_client::MirakurunClient::http(&arguments.host, arguments.port);
let decode = if arguments.decode { "?decode=1" } else { "" };
let path = format!(
"/api/channels/{}/{}/stream{decode}",
arguments.channel_type.as_str(),
arguments.channel
);
let mut stream = client
.get_stream(&path)
.await
.context("open remote stream")?;
let mut output = tokio::io::stdout();
while let Some(chunk) = stream.next().await {
output
.write_all(&chunk.context("read remote stream")?)
.await
.context("write stream to stdout")?;
}
output.flush().await.context("flush stdout")?;
Ok(())
}
pub async fn epgdump(arguments: EpgdumpArguments) -> Result<()> {
if arguments.destination.exists() && !arguments.force {
bail!(
"destination {} already exists; use --force to overwrite it",
arguments.destination.display()
);
}
let collector = if arguments.source == Path::new("-") {
collect_epg(tokio::io::stdin()).await?
} else {
let input = tokio::fs::File::open(&arguments.source)
.await
.with_context(|| format!("open {}", arguments.source.display()))?;
collect_epg(input).await?
};
if collector.packet_count() == 0 {
bail!("input contains no complete MPEG-TS packets");
}
let packet_count = collector.packet_count();
let section_count = collector.section_count();
let programs = collector.into_programs();
let encoded = serde_json::to_vec_pretty(&programs).context("serialize EPG programs")?;
mirakurun_core::persistence::ensure_parent(&arguments.destination, "EPG dump").await?;
mirakurun_core::persistence::atomic_write(&arguments.destination, &encoded, "EPG dump").await?;
tracing::info!(
packets = packet_count,
sections = section_count,
programs = programs.len(),
destination = %arguments.destination.display(),
"EPG dump completed"
);
Ok(())
}
async fn collect_epg<R>(mut input: R) -> Result<mirakurun_core::epg::EitCollector>
where
R: AsyncRead + Unpin,
{
let mut chunk = vec![0_u8; 64 * 1024];
let mut collector = mirakurun_core::epg::EitCollector::default();
loop {
let length = input.read(&mut chunk).await.context("read MPEG-TS input")?;
if length == 0 {
break;
}
collector.push(&chunk[..length]);
}
Ok(collector)
}
#[cfg(test)]
mod tests {
use mirakurun_core::ts::{PACKET_SIZE, crc32_mpeg2};
use tempfile::tempdir;
use super::{collect_epg, epgdump};
use crate::cli::EpgdumpArguments;
#[tokio::test]
async fn validates_transport_stream_input() {
let mut packet = vec![0xff; PACKET_SIZE * 2];
packet[0] = 0x47;
packet[PACKET_SIZE] = 0x47;
let collector = collect_epg(packet.as_slice()).await.expect("inspect TS");
assert_eq!(collector.packet_count(), 2);
}
#[tokio::test]
async fn epgdump_writes_program_json() {
let short_event = [
0x4d, 0x0d, b'j', b'p', b'n', 0x05, 0x0e, b'T', b'e', b's', b't', 0x03, 0x0e, b'O',
b'K',
];
let mut section = vec![
0x4e, 0xb0, 0x00, 0x00, 0x65, 0xc1, 0x00, 0x00, 0x00, 0x01, 0x7f, 0xf0, 0x00, 0x4e,
0x00, 0x01, 0x9e, 0x8b, 0x09, 0x00, 0x00, 0x01, 0x00, 0x00, 0x80, 0x0f,
];
section.extend_from_slice(&short_event);
let section_length = section.len() - 3 + 4;
section[1] = 0xb0 | u8::try_from(section_length >> 8).expect("section length");
section[2] = u8::try_from(section_length & 0xff).expect("section length");
section.extend_from_slice(&crc32_mpeg2(&section).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(&section);
let directory = tempdir().expect("create tempdir");
let source = directory.path().join("input.ts");
let destination = directory.path().join("programs.json");
tokio::fs::write(&source, packet)
.await
.expect("write fixture");
epgdump(EpgdumpArguments {
force: false,
source,
destination: destination.clone(),
})
.await
.expect("dump EPG");
let programs: Vec<mirakurun_types::Program> =
serde_json::from_slice(&tokio::fs::read(destination).await.expect("read EPG dump"))
.expect("parse EPG dump");
assert_eq!(programs.len(), 1);
assert_eq!(programs[0].name.as_deref(), Some("Test"));
}
}

View File

@@ -0,0 +1,786 @@
// 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},
sync::Arc,
time::Duration,
};
use anyhow::{Result, bail};
use chrono::{DateTime, Datelike, Local, Timelike};
use mirakurun_core::{
epg::EitCollector,
persistence::{channels_integrity, save_json_db},
service::ServiceCollector,
tuner::StreamRequest,
};
use mirakurun_types::{ConfigChannel, EventResource, EventType, JobItem, JobStatus, Service};
use serde_json::json;
use tokio::task::JoinHandle;
use tokio::{sync::broadcast, time::Instant};
use tokio_util::sync::CancellationToken;
use crate::state::AppState;
const MAX_HISTORY: usize = 50;
const HOUR_MS: u64 = 60 * 60 * 1000;
const DAY_MS: u64 = 24 * HOUR_MS;
pub fn spawn_scheduler(state: Arc<AppState>, shutdown: CancellationToken) -> JoinHandle<()> {
tokio::spawn(async move {
for schedule in state.job_schedules.read().await.clone() {
state
.emit_event(EventResource::JobSchedule, EventType::Create, &schedule)
.await;
}
let startup_state = state.clone();
let startup_shutdown = shutdown.clone();
tokio::spawn(async move {
tokio::select! {
() = startup_shutdown.cancelled() => {}
() = tokio::time::sleep(std::time::Duration::from_secs(5)) => {
queue_job(startup_state.clone(), "Program.GC", "Program GC").await;
tokio::select! {
() = startup_shutdown.cancelled() => {}
() = tokio::time::sleep(std::time::Duration::from_secs(5)) => {
queue_job(
startup_state.clone(),
"Service.Updater",
"Service Updater",
).await;
tokio::select! {
() = startup_shutdown.cancelled() => {}
() = tokio::time::sleep(std::time::Duration::from_secs(50)) => {
if startup_state
.job_schedules
.read()
.await
.iter()
.any(|schedule| schedule.key == "EPG.Gatherer")
{
queue_job(
startup_state,
"EPG.Gatherer",
"EPG Gatherer",
).await;
}
}
}
}
}
}
}
});
let mut timer = tokio::time::interval(std::time::Duration::from_secs(15));
timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut last_minute = None;
loop {
tokio::select! {
() = shutdown.cancelled() => break,
_ = timer.tick() => {
let now = Local::now();
let minute = now.timestamp().div_euclid(60);
if last_minute == Some(minute) {
continue;
}
last_minute = Some(minute);
run_matching_schedules(state.clone(), now).await;
}
}
}
})
}
pub async fn run_schedule(state: Arc<AppState>, key: &str) -> bool {
let schedule = state
.job_schedules
.read()
.await
.iter()
.find(|schedule| schedule.key == key)
.cloned();
let Some(schedule) = schedule else {
return false;
};
queue_job(state, &schedule.job.key, &schedule.job.name).await;
true
}
pub async fn abort(state: &Arc<AppState>, id: &str) -> bool {
let update = {
let mut jobs = state.jobs.write().await;
let Some(job) = jobs
.iter_mut()
.find(|job| job.id == id && job.status != JobStatus::Finished && !job.is_aborting)
else {
return false;
};
job.is_aborting = true;
job.updated_at = AppState::now_ms();
job.clone()
};
if let Some(token) = state.job_abort_tokens.lock().await.get(id) {
token.cancel();
}
state
.emit_event(EventResource::Job, EventType::Update, &update)
.await;
true
}
pub async fn rerun(state: Arc<AppState>, id: &str) -> bool {
let job = state
.jobs
.read()
.await
.iter()
.find(|job| {
job.id == id && job.status == JobStatus::Finished && job.is_rerunnable == Some(true)
})
.cloned();
let Some(job) = job else {
return false;
};
queue_job(state, &job.key, &job.name).await;
true
}
async fn run_matching_schedules(state: Arc<AppState>, now: DateTime<Local>) {
for schedule in state.job_schedules.read().await.clone() {
match matches_cron(&schedule.schedule, &now) {
Ok(true) => {
queue_job(state.clone(), &schedule.job.key, &schedule.job.name).await;
}
Ok(false) => {}
Err(error) => {
state
.log(format!("invalid job schedule {}: {error}", schedule.key))
.await;
}
}
}
}
async fn queue_job(state: Arc<AppState>, key: &str, name: &str) {
let now = AppState::now_ms();
let duplicate = state
.jobs
.read()
.await
.iter()
.any(|job| job.key == key && job.status != JobStatus::Finished);
if duplicate {
state
.log(format!(
"ignored duplicate job {key}; it is already queued or running"
))
.await;
return;
}
let job = {
let mut jobs = state.jobs.write().await;
if jobs
.iter()
.any(|job| job.key == key && job.status != JobStatus::Finished)
{
return;
}
let job = JobItem {
key: key.into(),
name: name.into(),
id: state.next_job_id(),
status: JobStatus::Queued,
retry_count: 0,
is_rerunnable: Some(true),
retry_on_abort: Some(false),
retry_on_fail: Some(false),
retry_max: Some(0),
retry_delay: Some(1000),
is_aborting: false,
has_aborted: None,
has_skipped: None,
has_failed: None,
error: None,
created_at: now,
updated_at: now,
started_at: None,
finished_at: None,
duration: Some(0),
};
jobs.insert(0, job.clone());
job
};
let token = CancellationToken::new();
state
.job_abort_tokens
.lock()
.await
.insert(job.id.clone(), token.clone());
state
.emit_event(EventResource::Job, EventType::Create, &job)
.await;
tokio::spawn(run_job(state, job.id, token));
}
async fn run_job(state: Arc<AppState>, id: String, abort: CancellationToken) {
let permit = tokio::select! {
() = abort.cancelled() => {
finish_job(&state, &id, true, None).await;
state.job_abort_tokens.lock().await.remove(&id);
return;
}
permit = state.job_semaphore.clone().acquire_owned() => {
if let Ok(permit) = permit {
permit
} else {
finish_job(&state, &id, false, Some("job executor closed".into())).await;
state.job_abort_tokens.lock().await.remove(&id);
return;
}
}
};
let Some(running) = update_job(&state, &id, |job| {
let now = AppState::now_ms();
job.status = JobStatus::Running;
job.started_at = Some(now);
job.updated_at = now;
})
.await
else {
return;
};
state
.emit_event(EventResource::Job, EventType::Update, &running)
.await;
state
.log(format!("job {} ({id}) started", running.key))
.await;
let work = execute_job(&state, &running.key);
let outcome = tokio::select! {
() = abort.cancelled() => (true, None),
result = work => match result {
Ok(()) => (false, None),
Err(error) => (false, Some(error.to_string())),
},
};
drop(permit);
finish_job(&state, &id, outcome.0, outcome.1).await;
state.job_abort_tokens.lock().await.remove(&id);
}
async fn execute_job(state: &Arc<AppState>, key: &str) -> Result<()> {
match key {
"Program.GC" => program_gc(state).await,
"EPG.Gatherer" => epg_gather(state).await,
"Service.Updater" => service_update(state).await,
_ => bail!("unknown job key: {key}"),
}
}
async fn program_gc(state: &Arc<AppState>) -> Result<()> {
let now = AppState::now_ms();
let short_expiry = now.saturating_sub(3 * HOUR_MS);
let long_expiry = now.saturating_sub(DAY_MS);
let maximum = now.saturating_add(9 * DAY_MS);
let mut programs = state.programs.read().await.clone();
let old_ids = programs
.iter()
.filter(|program| {
let end = program.start_at.saturating_add(program.duration);
let expiry = if program.duration == 1 {
long_expiry
} else {
short_expiry
};
expiry > end || maximum < program.start_at
})
.map(|program| program.id)
.collect::<HashSet<_>>();
programs.retain(|program| !old_ids.contains(&program.id));
let channels = state.config.read().await.channels.clone();
let integrity = channels_integrity(&channels)?;
save_json_db(&state.paths.programs_db, &programs, &integrity).await?;
*state.programs.write().await = programs;
for id in &old_ids {
state
.emit_event_value(
EventResource::Program,
EventType::Remove,
json!({ "id": id }),
)
.await;
}
state
.log(format!("Program GC removed {} programs", old_ids.len()))
.await;
Ok(())
}
async fn epg_gather(state: &Arc<AppState>) -> Result<()> {
let targets = epg_targets(state).await;
if targets.is_empty() {
state.log("EPG gathering skipped: no services").await;
return Ok(());
}
let retrieval_time = state
.config
.read()
.await
.server
.epg_retrieval_time
.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?;
}
Ok(())
}
async fn service_update(state: &Arc<AppState>) -> Result<()> {
let channels = state
.config
.read()
.await
.channels
.iter()
.filter(|channel| channel.is_disabled != Some(true))
.cloned()
.collect::<Vec<_>>();
for channel in channels {
let discovered = discover_services(state, channel.clone()).await?;
merge_services(state, &channel, discovered).await?;
}
Ok(())
}
pub(crate) async fn discover_services(
state: &Arc<AppState>,
channel: ConfigChannel,
) -> Result<Vec<Service>> {
let mut subscription = state
.tuners
.subscribe(StreamRequest {
channel: channel.clone(),
service_id: None,
event_id: None,
priority: -1,
agent: Some("Mirakurun:getServices()".into()),
url: None,
disable_decoder: true,
})
.await?;
let deadline = Instant::now() + Duration::from_secs(15);
let mut collector = ServiceCollector::new(channel);
while Instant::now() < deadline && !collector.is_complete() {
let wait = deadline
.saturating_duration_since(Instant::now())
.min(Duration::from_secs(1));
match tokio::time::timeout(wait, subscription.receiver.recv()).await {
Ok(Ok(chunk)) => collector.push(&chunk),
Ok(Err(broadcast::error::RecvError::Lagged(skipped))) => {
state
.buffer_overflow_count
.fetch_add(skipped, std::sync::atomic::Ordering::Relaxed);
}
Ok(Err(broadcast::error::RecvError::Closed)) => {
bail!("tuner stream closed while discovering services");
}
Err(_) => {}
}
}
if !collector.is_complete() {
bail!(
"service discovery timed out after reading {} packets",
collector.packet_count()
);
}
Ok(collector.into_services())
}
async fn merge_services(
state: &Arc<AppState>,
channel: &ConfigChannel,
mut discovered: Vec<Service>,
) -> Result<()> {
let mut services = state.services.read().await.clone();
let old_on_channel = services
.iter()
.filter(|service| service_matches_channel(service, channel))
.cloned()
.map(|service| (service.id, service))
.collect::<HashMap<_, _>>();
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;
service.epg_ready = previous.epg_ready;
service.epg_updated_at = previous.epg_updated_at;
}
}
let discovered_ids = discovered
.iter()
.map(|service| service.id)
.collect::<HashSet<_>>();
services.retain(|service| {
!service_matches_channel(service, channel) || discovered_ids.contains(&service.id)
});
for service in &discovered {
if let Some(existing) = services
.iter_mut()
.find(|existing| existing.id == service.id)
{
existing.clone_from(service);
} else {
services.push(service.clone());
}
}
services.sort_by_key(|service| service.id);
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 = services;
for removed in old_on_channel
.keys()
.filter(|id| !discovered_ids.contains(id))
{
state
.emit_event_value(
EventResource::Service,
EventType::Remove,
json!({ "id": removed }),
)
.await;
}
for service in discovered {
let event_type = if old_on_channel.contains_key(&service.id) {
EventType::Update
} else {
EventType::Create
};
state
.emit_event(EventResource::Service, event_type, &service)
.await;
}
state
.log(format!(
"service discovery for {}/{} stored {} services",
channel.channel_type.as_str(),
channel.channel,
discovered_ids.len()
))
.await;
Ok(())
}
fn service_matches_channel(service: &Service, channel: &ConfigChannel) -> bool {
service.channel.as_deref().is_some_and(|service_channel| {
service_channel.channel_type == channel.channel_type
&& service_channel.channel == channel.channel
})
}
async fn epg_targets(state: &Arc<AppState>) -> Vec<(u16, ConfigChannel)> {
let services = state.services.read().await.clone();
let channels = state.config.read().await.channels.clone();
let mut targets = HashMap::new();
for service in services {
let Some(service_channel) = service.channel.as_deref() else {
continue;
};
let Some(channel) = channels.iter().find(|channel| {
channel.is_disabled != Some(true)
&& channel.channel_type == service_channel.channel_type
&& channel.channel == service_channel.channel
}) else {
continue;
};
targets
.entry(service.network_id)
.or_insert_with(|| channel.clone());
}
let mut targets = targets.into_iter().collect::<Vec<_>>();
targets.sort_by_key(|(network_id, _)| *network_id);
targets
}
async fn gather_network(
state: &Arc<AppState>,
channel: ConfigChannel,
retrieval_time_ms: u64,
) -> Result<Vec<mirakurun_types::Program>> {
let mut subscription = state
.tuners
.subscribe(StreamRequest {
channel,
service_id: None,
event_id: None,
priority: -1,
agent: Some("Mirakurun:getEPG()".into()),
url: None,
disable_decoder: true,
})
.await?;
let started = Instant::now();
let deadline = started + Duration::from_millis(retrieval_time_ms.max(1000));
let mut last_section = started;
let mut section_count = 0;
let mut collector = EitCollector::default();
loop {
let now = Instant::now();
if now >= deadline
|| (section_count > 0 && now.duration_since(last_section) >= Duration::from_secs(3))
{
break;
}
let wait = deadline
.saturating_duration_since(now)
.min(Duration::from_secs(1));
match tokio::time::timeout(wait, subscription.receiver.recv()).await {
Ok(Ok(chunk)) => {
collector.push(&chunk);
if collector.section_count() != section_count {
section_count = collector.section_count();
last_section = Instant::now();
}
}
Ok(Err(broadcast::error::RecvError::Lagged(skipped))) => {
state
.buffer_overflow_count
.fetch_add(skipped, std::sync::atomic::Ordering::Relaxed);
}
Ok(Err(broadcast::error::RecvError::Closed)) => {
bail!("tuner stream closed while gathering EPG");
}
Err(_) => {}
}
}
if collector.section_count() == 0 {
bail!("no EIT sections received before the EPG retrieval timeout");
}
Ok(collector.into_programs())
}
async fn merge_epg(
state: &Arc<AppState>,
network_id: u16,
incoming: Vec<mirakurun_types::Program>,
) -> Result<()> {
let mut programs = state.programs.read().await.clone();
let existing_ids = programs
.iter()
.map(|program| program.id)
.collect::<HashSet<_>>();
let incoming_ids = incoming
.iter()
.map(|program| program.id)
.collect::<HashSet<_>>();
for program in &incoming {
if let Some(current) = programs.iter_mut().find(|current| current.id == program.id) {
current.clone_from(program);
} else {
programs.push(program.clone());
}
}
programs.sort_by_key(|program| (program.start_at, program.id));
let channels = state.config.read().await.channels.clone();
let integrity = channels_integrity(&channels)?;
save_json_db(&state.paths.programs_db, &programs, &integrity).await?;
*state.programs.write().await = programs;
let now = AppState::now_ms();
let mut services = state.services.read().await.clone();
let mut updated_services = Vec::new();
for service in services
.iter_mut()
.filter(|service| service.network_id == network_id)
{
service.epg_ready = Some(true);
service.epg_updated_at = Some(now);
updated_services.push(service.clone());
}
save_json_db(&state.paths.services_db, &services, &integrity).await?;
*state.services.write().await = services;
for program in incoming {
let event_type = if existing_ids.contains(&program.id) {
EventType::Update
} else {
EventType::Create
};
state
.emit_event(EventResource::Program, event_type, &program)
.await;
}
for service in updated_services {
state
.emit_event(EventResource::Service, EventType::Update, &service)
.await;
}
state
.log(format!(
"Network#{network_id} EPG gathering stored {} programs",
incoming_ids.len()
))
.await;
Ok(())
}
struct GatheringNetworkGuard {
state: Arc<AppState>,
network_id: u16,
}
impl GatheringNetworkGuard {
async fn new(state: Arc<AppState>, network_id: u16) -> Self {
state.gathering_networks.write().await.insert(network_id);
Self { state, network_id }
}
}
impl Drop for GatheringNetworkGuard {
fn drop(&mut self) {
let state = self.state.clone();
let network_id = self.network_id;
tokio::spawn(async move {
state.gathering_networks.write().await.remove(&network_id);
});
}
}
async fn finish_job(state: &Arc<AppState>, id: &str, aborted: bool, error: Option<String>) {
let finished = update_job(state, id, |job| {
let now = AppState::now_ms();
job.status = JobStatus::Finished;
job.updated_at = now;
job.finished_at = Some(now);
job.duration = job
.started_at
.map(|started_at| now.saturating_sub(started_at))
.or(Some(0));
job.is_aborting = aborted;
job.has_aborted = Some(aborted);
job.has_skipped = Some(false);
job.has_failed = Some(error.is_some());
job.error.clone_from(&error);
})
.await;
let Some(finished) = finished else {
return;
};
trim_history(state).await;
state
.emit_event(EventResource::Job, EventType::Update, &finished)
.await;
let outcome = if aborted {
"aborted"
} else if finished.has_failed == Some(true) {
"failed"
} else {
"completed"
};
state
.log(format!("job {} ({id}) {outcome}", finished.key))
.await;
}
async fn update_job(
state: &Arc<AppState>,
id: &str,
update: impl FnOnce(&mut JobItem),
) -> Option<JobItem> {
let mut jobs = state.jobs.write().await;
let job = jobs.iter_mut().find(|job| job.id == id)?;
update(job);
Some(job.clone())
}
async fn trim_history(state: &Arc<AppState>) {
let mut jobs = state.jobs.write().await;
let mut finished_seen = 0;
jobs.retain(|job| {
if job.status != JobStatus::Finished {
return true;
}
finished_seen += 1;
finished_seen <= MAX_HISTORY
});
}
fn matches_cron(expression: &str, date: &DateTime<Local>) -> Result<bool> {
let fields = expression.split_ascii_whitespace().collect::<Vec<_>>();
if fields.len() != 5 {
bail!("expected five cron fields");
}
Ok(matches_cron_field(fields[0], date.minute(), 0, 59)?
&& matches_cron_field(fields[1], date.hour(), 0, 23)?
&& matches_cron_field(fields[2], date.day(), 1, 31)?
&& matches_cron_field(fields[3], date.month(), 1, 12)?
&& matches_cron_field(fields[4], date.weekday().num_days_from_sunday(), 0, 6)?)
}
fn matches_cron_field(field: &str, value: u32, minimum: u32, maximum: u32) -> Result<bool> {
for part in field.split(',') {
let (range, step) = if let Some((range, step)) = part.split_once('/') {
(range, step.parse::<u32>()?)
} else {
(part, 1)
};
if step == 0 {
bail!("invalid cron step in {part}");
}
let (start, end) = if range == "*" {
(minimum, maximum)
} else if let Some((start, end)) = range.split_once('-') {
(start.parse::<u32>()?, end.parse::<u32>()?)
} else {
let exact = range.parse::<u32>()?;
(exact, exact)
};
if start < minimum || end > maximum || start > end {
bail!("cron range {range} is outside {minimum}..={maximum}");
}
if (start..=end).contains(&value) && (value - start) % step == 0 {
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use chrono::{Local, TimeZone};
use super::{matches_cron, matches_cron_field};
#[test]
fn cron_fields_support_lists_ranges_and_steps() {
assert!(matches_cron_field("20,50", 50, 0, 59).expect("valid field"));
assert!(matches_cron_field("1-10/3", 7, 0, 59).expect("valid field"));
assert!(!matches_cron_field("*/15", 14, 0, 59).expect("valid field"));
assert!(matches_cron_field("*/15", 15, 0, 59).expect("valid field"));
}
#[test]
fn cron_expression_matches_local_time() {
let date = Local
.with_ymd_and_hms(2026, 7, 31, 6, 5, 0)
.single()
.expect("unambiguous local time");
assert!(matches_cron("5 6 * * *", &date).expect("valid expression"));
assert!(!matches_cron("6 6 * * *", &date).expect("valid expression"));
}
}

View File

@@ -0,0 +1,37 @@
// 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.
mod access;
mod api;
mod assets;
mod cli;
mod commands;
mod jobs;
mod rpc;
mod scan;
mod server;
mod state;
use anyhow::Result;
use clap::Parser;
use cli::{Cli, Command};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
let paths = cli.paths.resolve();
match cli.command.unwrap_or(Command::Serve) {
Command::Serve => server::serve(paths).await,
Command::Epgdump(arguments) => commands::epgdump(arguments).await,
Command::Remote(arguments) => commands::remote(arguments).await,
}
}

View File

@@ -0,0 +1,219 @@
// 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::HashSet, sync::Arc};
use axum::{
extract::{
State,
ws::{Message, WebSocket, WebSocketUpgrade},
},
response::Response,
};
use futures_util::{SinkExt, StreamExt};
use serde::Deserialize;
use serde_json::{Value, json};
use tokio::sync::broadcast;
use crate::{
api::{build_services, build_status},
state::{AppState, RpcConnectionGuard},
};
#[derive(Debug, Deserialize)]
struct RpcRequest {
#[serde(default)]
jsonrpc: Option<String>,
method: String,
#[serde(default)]
params: Value,
#[serde(default)]
id: Option<Value>,
}
pub async fn upgrade(State(state): State<Arc<AppState>>, websocket: WebSocketUpgrade) -> Response {
websocket
.max_message_size(1024 * 1024)
.on_upgrade(move |socket| connection(socket, state))
}
async fn connection(socket: WebSocket, state: Arc<AppState>) {
let _guard = RpcConnectionGuard::new(state.clone());
let (mut sender, mut receiver) = socket.split();
let mut events = state.event_sender.subscribe();
let mut logs = state.log_sender.subscribe();
let mut rooms: HashSet<String> = HashSet::new();
loop {
tokio::select! {
message = receiver.next() => {
let Some(message) = message else {
break;
};
match message {
Ok(Message::Text(text)) => {
let response = handle_request(&state, &mut rooms, text.as_str()).await;
if let Some(response) = response {
if sender.send(Message::Text(response.into())).await.is_err() {
break;
}
}
}
Ok(Message::Ping(payload)) => {
if sender.send(Message::Pong(payload)).await.is_err() {
break;
}
}
Ok(Message::Close(_)) | Err(_) => break,
Ok(_) => {}
}
}
event = events.recv(), if rooms.iter().any(|room| room.starts_with("events:")) => {
match event {
Ok(event) => {
let room = format!(
"events:{}",
serde_json::to_value(event.resource)
.ok()
.and_then(|value| value.as_str().map(str::to_owned))
.unwrap_or_default()
);
if rooms.contains(&room) {
let notification = json!({
"jsonrpc": "2.0",
"method": "events",
"params": {"array": [event]}
});
if sender.send(Message::Text(notification.to_string().into())).await.is_err() {
break;
}
}
}
Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Closed) => break,
}
}
line = logs.recv(), if rooms.contains("logs") => {
match line {
Ok(line) => {
let notification = json!({
"jsonrpc": "2.0",
"method": "logs",
"params": {"array": [line]}
});
if sender.send(Message::Text(notification.to_string().into())).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Closed) => break,
}
}
}
}
}
async fn handle_request(
state: &Arc<AppState>,
rooms: &mut HashSet<String>,
text: &str,
) -> Option<String> {
let request: RpcRequest = match serde_json::from_str(text) {
Ok(request) => request,
Err(error) => {
return Some(
json!({
"jsonrpc": "2.0",
"error": {"code": -32700, "message": error.to_string()},
"id": Value::Null
})
.to_string(),
);
}
};
if request
.jsonrpc
.as_deref()
.is_some_and(|version| version != "2.0")
{
return request.id.map(|id| {
json!({
"jsonrpc": "2.0",
"error": {"code": -32600, "message": "Invalid Request"},
"id": id
})
.to_string()
});
}
let result = match request.method.as_str() {
"join" => update_rooms(rooms, &request.params, true),
"leave" => update_rooms(rooms, &request.params, false),
"getStatus" => serde_json::to_value(build_status(state).await),
"getServices" => serde_json::to_value(build_services(state).await),
"getTuners" => serde_json::to_value(state.tuners.statuses().await),
"getJobs" => serde_json::to_value(state.jobs.read().await.clone()),
"getJobSchedules" => serde_json::to_value(state.job_schedules.read().await.clone()),
_ => {
return request.id.map(|id| {
json!({
"jsonrpc": "2.0",
"error": {"code": -32601, "message": "Method not found"},
"id": id
})
.to_string()
});
}
};
request.id.map(|id| match result {
Ok(result) => json!({"jsonrpc": "2.0", "result": result, "id": id}).to_string(),
Err(error) => json!({
"jsonrpc": "2.0",
"error": {"code": -32602, "message": error.to_string()},
"id": id
})
.to_string(),
})
}
fn update_rooms(
rooms: &mut HashSet<String>,
params: &Value,
join: bool,
) -> serde_json::Result<Value> {
let room_names: Vec<String> = serde_json::from_value(
params
.get("rooms")
.cloned()
.unwrap_or_else(|| Value::Array(Vec::new())),
)?;
for room in room_names {
if join {
rooms.insert(room);
} else {
rooms.remove(&room);
}
}
Ok(Value::Null)
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use serde_json::json;
use super::update_rooms;
#[test]
fn joins_and_leaves_rooms() {
let mut rooms = HashSet::new();
update_rooms(&mut rooms, &json!({"rooms": ["events:program"]}), true).expect("join room");
assert!(rooms.contains("events:program"));
update_rooms(&mut rooms, &json!({"rooms": ["events:program"]}), false).expect("leave room");
assert!(rooms.is_empty());
}
}

View File

@@ -0,0 +1,462 @@
// 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, sync::Arc};
use anyhow::{Context, Result, bail};
use mirakurun_core::config::save_channels;
use mirakurun_types::{ChannelScanPhase, ChannelType, ConfigChannel, Service};
use tokio_util::sync::CancellationToken;
use crate::{jobs, state::AppState};
const SUPPORTED_SERVICE_TYPES: &[u8] = &[0x01, 0x02, 0xa1, 0xa4, 0xa5, 0xad, 0xc0];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ScanMode {
Channel,
Service,
}
#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct ScanOptions {
channel_type: ChannelType,
channels: Vec<String>,
mode: ScanMode,
set_disabled_on_add: bool,
dry_run: bool,
refresh: bool,
asynchronous: bool,
}
impl ScanOptions {
pub fn parse(query: &HashMap<String, String>) -> std::result::Result<Self, String> {
let channel_type = query
.get("type")
.map_or(Ok(ChannelType::Gr), |value| value.parse())?;
let minimum = parse_u32(query, "minCh")?;
let maximum = parse_u32(query, "maxCh")?;
let use_subchannel = flag(query, "useSubCh");
let mode = match query.get("scanMode").map(String::as_str) {
Some("Channel") => ScanMode::Channel,
Some("Service") => ScanMode::Service,
Some(value) => return Err(format!("unsupported scanMode: {value}")),
None if channel_type == ChannelType::Gr => ScanMode::Channel,
None => ScanMode::Service,
};
let set_disabled_on_add = query
.get("setDisabledOnAdd")
.map_or(channel_type != ChannelType::Gr, |value| boolean(value));
let custom_format = query.get("channelNameFormat").map(String::as_str);
let skip = query
.get("skipCh")
.map(|value| {
value
.split(',')
.filter_map(|item| item.parse::<u32>().ok())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let channels = generate_channels(
channel_type,
minimum,
maximum,
parse_u32(query, "minSubCh")?,
parse_u32(query, "maxSubCh")?,
use_subchannel,
custom_format,
)?
.into_iter()
.filter(|(number, _)| !skip.contains(number))
.map(|(_, channel)| channel)
.collect::<Vec<_>>();
if channels.is_empty() || channels.len() > 500 {
return Err("scan range must contain between 1 and 500 channels".into());
}
Ok(Self {
channel_type,
channels,
mode,
set_disabled_on_add,
dry_run: flag(query, "dryRun"),
refresh: flag(query, "refresh"),
asynchronous: flag(query, "async"),
})
}
#[must_use]
pub const fn is_asynchronous(&self) -> bool {
self.asynchronous
}
}
pub async fn initialize(
state: &Arc<AppState>,
options: &ScanOptions,
) -> std::result::Result<CancellationToken, &'static str> {
let mut status = state.scan_status.write().await;
if status.is_scanning {
return Err("Already Scanning");
}
let now = AppState::now_ms();
status.is_scanning = true;
status.status = ChannelScanPhase::Scanning;
status.channel_type = Some(options.channel_type);
status.dry_run = Some(options.dry_run);
status.progress = Some(0.0);
status.current_channel = Some(String::new());
status.scan_log = Some(vec![format!(
"Channel scan started for {}.\n",
options.channel_type.as_str()
)]);
status.new_count = Some(0);
status.takeover_count = Some(0);
status.result = Some(Vec::new());
status.start_time = Some(now);
status.update_time = Some(now);
drop(status);
let cancel = CancellationToken::new();
*state.scan_cancel.lock().await = Some(cancel.clone());
Ok(cancel)
}
pub async fn run(state: Arc<AppState>, options: ScanOptions, cancel: CancellationToken) {
let result = run_inner(&state, &options, &cancel).await;
if let Err(error) = result {
let mut status = state.scan_status.write().await;
status.status = if cancel.is_cancelled() {
ChannelScanPhase::Cancelled
} else {
ChannelScanPhase::Error
};
status
.scan_log
.get_or_insert_with(Vec::new)
.push(format!("Error: {error}\n"));
status.update_time = Some(AppState::now_ms());
}
state.scan_status.write().await.is_scanning = false;
*state.scan_cancel.lock().await = None;
}
#[allow(clippy::too_many_lines)]
async fn run_inner(
state: &Arc<AppState>,
options: &ScanOptions,
cancel: &CancellationToken,
) -> Result<()> {
let old_channels = state.config.read().await.channels.clone();
let mut result = old_channels
.iter()
.filter(|channel| channel.channel_type != options.channel_type)
.cloned()
.collect::<Vec<_>>();
let total = options.channels.len();
let mut new_count = 0usize;
let mut takeover_count = 0usize;
for (index, channel_name) in options.channels.iter().enumerate() {
if cancel.is_cancelled() {
bail!("scan cancellation requested");
}
update_progress(state, channel_name, index, total).await;
let existing = old_channels
.iter()
.filter(|channel| {
channel.channel_type == options.channel_type && channel.channel == *channel_name
})
.cloned()
.collect::<Vec<_>>();
if !options.refresh
&& existing
.iter()
.any(|channel| channel.is_disabled != Some(true))
{
takeover_count += existing.len();
result.extend(existing);
append_log(
state,
format!("{channel_name}: kept existing enabled configuration.\n"),
)
.await;
continue;
}
let probe = ConfigChannel {
name: format!("{}{channel_name}", options.channel_type.as_str()),
channel_type: options.channel_type,
channel: channel_name.clone(),
service_id: None,
tsmf_rel_ts: None,
command_vars: None,
is_disabled: None,
satelite: None,
satellite: None,
space: None,
freq: None,
polarity: None,
};
let discovered = tokio::select! {
() = cancel.cancelled() => bail!("scan cancellation requested"),
result = jobs::discover_services(state, probe) => result,
};
let services = match discovered {
Ok(services) => services
.into_iter()
.filter(|service| SUPPORTED_SERVICE_TYPES.contains(&service.service_type))
.collect::<Vec<_>>(),
Err(error) => {
append_log(state, format!("{channel_name}: no services ({error}).\n")).await;
continue;
}
};
if services.is_empty() {
append_log(state, format!("{channel_name}: no supported services.\n")).await;
continue;
}
let generated = generate_items(options, channel_name, &services);
new_count += generated.len();
append_log(
state,
format!(
"{channel_name}: found {} services and generated {} entries.\n",
services.len(),
generated.len()
),
)
.await;
result.extend(generated);
}
result.sort_by(|left, right| {
channel_type_order(left.channel_type)
.cmp(&channel_type_order(right.channel_type))
.then_with(|| left.channel.cmp(&right.channel))
.then_with(|| left.service_id.cmp(&right.service_id))
});
if !options.dry_run {
save_channels(&state.paths.channels, &result)
.await
.context("save scanned channel configuration")?;
state.config.write().await.channels.clone_from(&result);
}
let mut status = state.scan_status.write().await;
status.status = ChannelScanPhase::Completed;
status.progress = Some(100.0);
status.current_channel = None;
status.new_count = Some(new_count);
status.takeover_count = Some(takeover_count);
status.result = Some(result);
status.update_time = Some(AppState::now_ms());
status
.scan_log
.get_or_insert_with(Vec::new)
.push(if options.dry_run {
"Channel scan completed (dry run).\n".into()
} else {
"Channel scan completed and saved. Restart is recommended.\n".into()
});
Ok(())
}
fn generate_items(
options: &ScanOptions,
channel_name: &str,
services: &[Service],
) -> Vec<ConfigChannel> {
if options.mode == ScanMode::Service {
return services
.iter()
.map(|service| ConfigChannel {
name: nonempty_name(&service.name).unwrap_or_else(|| {
format!(
"{}{channel_name}:{}",
options.channel_type.as_str(),
service.service_id
)
}),
channel_type: options.channel_type,
channel: channel_name.into(),
service_id: Some(service.service_id),
tsmf_rel_ts: None,
command_vars: None,
is_disabled: Some(options.set_disabled_on_add),
satelite: None,
satellite: None,
space: None,
freq: None,
polarity: None,
})
.collect();
}
vec![ConfigChannel {
name: common_service_prefix(services)
.unwrap_or_else(|| format!("{}{channel_name}", options.channel_type.as_str())),
channel_type: options.channel_type,
channel: channel_name.into(),
service_id: None,
tsmf_rel_ts: None,
command_vars: None,
is_disabled: Some(options.set_disabled_on_add),
satelite: None,
satellite: None,
space: None,
freq: None,
polarity: None,
}]
}
fn common_service_prefix(services: &[Service]) -> Option<String> {
let first = services.first()?.name.trim();
let mut prefix = first.chars().collect::<Vec<_>>();
for service in &services[1..] {
let name = service.name.trim().chars().collect::<Vec<_>>();
let shared = prefix
.iter()
.zip(name)
.take_while(|(left, right)| left == &right)
.count();
prefix.truncate(shared);
}
nonempty_name(&prefix.into_iter().collect::<String>())
}
fn nonempty_name(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| value.to_owned())
}
async fn update_progress(state: &Arc<AppState>, channel: &str, index: usize, total: usize) {
let mut status = state.scan_status.write().await;
status.current_channel = Some(channel.into());
let index = u32::try_from(index).unwrap_or(u32::MAX);
let total = u32::try_from(total).unwrap_or(u32::MAX);
status.progress = Some((f64::from(index) / f64::from(total)) * 100.0);
status.update_time = Some(AppState::now_ms());
}
async fn append_log(state: &Arc<AppState>, line: String) {
let mut status = state.scan_status.write().await;
status.scan_log.get_or_insert_with(Vec::new).push(line);
status.update_time = Some(AppState::now_ms());
}
fn generate_channels(
channel_type: ChannelType,
minimum: Option<u32>,
maximum: Option<u32>,
minimum_subchannel: Option<u32>,
maximum_subchannel: Option<u32>,
use_subchannel: bool,
custom_format: Option<&str>,
) -> std::result::Result<Vec<(u32, String)>, String> {
let (default_minimum, default_maximum, default_format) = match channel_type {
ChannelType::Gr => (13, 62, "{ch}"),
ChannelType::Bs if use_subchannel => (1, 23, "BS{ch00}_{subch}"),
ChannelType::Bs => (101, 256, "{ch}"),
ChannelType::Cs => (2, 24, "CS{ch}"),
ChannelType::Sky => return Err("SKY channel scan is not supported".into()),
};
let minimum = minimum.unwrap_or(default_minimum);
let maximum = maximum.unwrap_or(default_maximum);
if minimum > maximum {
return Err("minCh must not be greater than maxCh".into());
}
let format = custom_format.unwrap_or(default_format);
let mut channels = Vec::new();
if channel_type == ChannelType::Bs && use_subchannel {
let minimum_subchannel = minimum_subchannel.unwrap_or(0);
let maximum_subchannel = maximum_subchannel.unwrap_or(3);
if minimum_subchannel > maximum_subchannel {
return Err("minSubCh must not be greater than maxSubCh".into());
}
for channel in minimum..=maximum {
for subchannel in minimum_subchannel..=maximum_subchannel {
channels.push((channel, format_channel(format, channel, Some(subchannel))));
}
}
} else {
channels.extend(
(minimum..=maximum).map(|channel| (channel, format_channel(format, channel, None))),
);
}
Ok(channels)
}
fn format_channel(format: &str, channel: u32, subchannel: Option<u32>) -> String {
let subchannel = subchannel.unwrap_or(0);
format
.replace("{ch000}", &format!("{channel:03}"))
.replace("{ch00}", &format!("{channel:02}"))
.replace("{ch0}", &channel.to_string())
.replace("{ch}", &channel.to_string())
.replace("{subch000}", &format!("{subchannel:03}"))
.replace("{subch00}", &format!("{subchannel:02}"))
.replace("{subch0}", &subchannel.to_string())
.replace("{subch}", &subchannel.to_string())
}
fn parse_u32(
query: &HashMap<String, String>,
key: &str,
) -> std::result::Result<Option<u32>, String> {
query.get(key).map_or(Ok(None), |value| {
value
.parse()
.map(Some)
.map_err(|_| format!("{key} must be an unsigned integer"))
})
}
fn flag(query: &HashMap<String, String>, key: &str) -> bool {
query.get(key).is_some_and(|value| boolean(value))
}
fn boolean(value: &str) -> bool {
value.is_empty() || matches!(value, "1" | "true" | "yes" | "on")
}
const fn channel_type_order(channel_type: ChannelType) -> u8 {
match channel_type {
ChannelType::Gr => 1,
ChannelType::Bs => 2,
ChannelType::Cs => 3,
ChannelType::Sky => 4,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::ScanOptions;
#[test]
fn generates_bounded_ground_scan() {
let query = HashMap::from([
("type".into(), "GR".into()),
("minCh".into(), "13".into()),
("maxCh".into(), "15".into()),
]);
let options = ScanOptions::parse(&query).expect("parse scan");
assert_eq!(options.channels, ["13", "14", "15"]);
}
#[test]
fn formats_bs_subchannels() {
let query = HashMap::from([
("type".into(), "BS".into()),
("minCh".into(), "1".into()),
("maxCh".into(), "1".into()),
("minSubCh".into(), "0".into()),
("maxSubCh".into(), "2".into()),
("useSubCh".into(), "true".into()),
]);
let options = ScanOptions::parse(&query).expect("parse scan");
assert_eq!(options.channels, ["BS01_0", "BS01_1", "BS01_2"]);
}
}

View File

@@ -0,0 +1,172 @@
// 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::{
net::SocketAddr,
path::Path,
sync::{Arc, atomic::Ordering},
};
use anyhow::{Context, Result, bail};
use axum::{Router, middleware, routing::get};
use mirakurun_core::config::ConfigPaths;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use tower_http::{
cors::{Any, CorsLayer},
trace::TraceLayer,
};
use crate::{access, api, assets, jobs, rpc, state::AppState};
pub async fn serve(paths: ConfigPaths) -> Result<()> {
let state = AppState::load(paths)
.await
.context("load Mirakurun state")?;
state
.log(format!("Mirakurun {} starting", env!("CARGO_PKG_VERSION")))
.await;
let server_config = state.config.read().await.server.clone();
let router = api::router()
.route("/rpc", get(rpc::upgrade))
.fallback(assets::serve)
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_headers(Any)
.allow_methods(Any),
)
.layer(middleware::from_fn_with_state(
state.clone(),
access::enforce,
))
.layer(TraceLayer::new_for_http())
.with_state(state.clone());
let shutdown = state.shutdown.clone();
let scheduler = jobs::spawn_scheduler(state.clone(), shutdown.clone());
let mut servers = JoinSet::new();
if let Some(port) = server_config.port {
let address: SocketAddr = if server_config.disable_ipv6 == Some(true) {
format!("0.0.0.0:{port}")
} else {
format!("[::]:{port}")
}
.parse()
.context("parse listen address")?;
let listener = tokio::net::TcpListener::bind(address)
.await
.with_context(|| format!("bind TCP listener {address}"))?;
tracing::info!(%address, "listening for HTTP");
spawn_tcp_server(&mut servers, listener, router.clone(), shutdown.clone());
}
if let Some(socket_path) = server_config.path.as_deref() {
if !socket_path.is_empty() {
prepare_unix_socket(Path::new(socket_path)).await?;
let listener = tokio::net::UnixListener::bind(socket_path)
.with_context(|| format!("bind Unix socket {socket_path}"))?;
tracing::info!(path = socket_path, "listening for HTTP on Unix socket");
spawn_unix_server(&mut servers, listener, router, shutdown.clone());
}
}
if servers.is_empty() {
bail!("neither TCP port nor Unix socket is enabled");
}
tokio::select! {
signal = shutdown_signal() => {
signal.context("wait for termination signal")?;
tracing::info!("shutdown signal received");
}
() = shutdown.cancelled() => {
tracing::info!("application shutdown requested");
}
result = servers.join_next() => {
match result {
Some(Ok(Ok(()))) => tracing::warn!("HTTP listener stopped"),
Some(Ok(Err(error))) => return Err(error),
Some(Err(error)) => return Err(error.into()),
None => bail!("all HTTP listeners stopped"),
}
}
}
shutdown.cancel();
scheduler.await.context("job scheduler failed")?;
while let Some(result) = servers.join_next().await {
result??;
}
if let Some(socket_path) = server_config.path.as_deref() {
remove_socket_if_present(Path::new(socket_path)).await?;
}
if state.restart_requested.load(Ordering::Acquire) {
bail!("restart requested");
}
Ok(())
}
async fn shutdown_signal() -> Result<()> {
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.context("install SIGTERM handler")?;
tokio::select! {
result = tokio::signal::ctrl_c() => {
result.context("wait for SIGINT")?;
}
_ = terminate.recv() => {}
}
Ok(())
}
fn spawn_tcp_server(
servers: &mut JoinSet<Result<()>>,
listener: tokio::net::TcpListener,
router: Router,
shutdown: CancellationToken,
) {
servers.spawn(async move {
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown.cancelled_owned())
.await
.context("HTTP server failed")
});
}
fn spawn_unix_server(
servers: &mut JoinSet<Result<()>>,
listener: tokio::net::UnixListener,
router: Router,
shutdown: CancellationToken,
) {
servers.spawn(async move {
axum::serve(listener, router)
.with_graceful_shutdown(shutdown.cancelled_owned())
.await
.context("Unix HTTP server failed")
});
}
async fn prepare_unix_socket(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("create Unix socket directory {}", parent.display()))?;
}
remove_socket_if_present(path).await
}
async fn remove_socket_if_present(path: &Path) -> Result<()> {
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error).with_context(|| format!("remove Unix socket {}", path.display())),
}
}
#[allow(dead_code)]
fn _assert_send_sync(_: Arc<AppState>) {}

View File

@@ -0,0 +1,284 @@
// 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, VecDeque},
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::{SystemTime, UNIX_EPOCH},
};
use mirakurun_core::{
Result,
config::{ConfigPaths, LoadedConfig},
persistence::{channels_integrity, load_json_db},
tuner::TunerManager,
};
use mirakurun_types::{
ChannelScanStatus, Event, EventResource, EventType, JobItem, JobReference, JobScheduleItem,
Program, Service,
};
use serde::Serialize;
use serde_json::Value;
use tokio::sync::{Mutex, Semaphore};
use tokio::sync::{RwLock, broadcast};
use tokio_util::sync::CancellationToken;
const EVENT_CAPACITY: usize = 1024;
const LOG_CAPACITY: usize = 2048;
#[derive(Debug)]
pub struct AppState {
pub paths: ConfigPaths,
pub config: RwLock<LoadedConfig>,
pub services: RwLock<Vec<Service>>,
pub programs: RwLock<Vec<Program>>,
pub tuners: TunerManager,
pub jobs: RwLock<Vec<JobItem>>,
pub job_schedules: RwLock<Vec<JobScheduleItem>>,
pub job_abort_tokens: Mutex<HashMap<String, CancellationToken>>,
pub job_semaphore: Arc<Semaphore>,
job_id_prefix: String,
next_job_id: AtomicU64,
pub scan_status: RwLock<ChannelScanStatus>,
pub scan_cancel: Mutex<Option<CancellationToken>>,
pub gathering_networks: RwLock<HashSet<u16>>,
pub event_sender: broadcast::Sender<Event>,
pub event_history: RwLock<VecDeque<Event>>,
pub log_sender: broadcast::Sender<String>,
pub log_history: RwLock<VecDeque<String>>,
pub rpc_count: AtomicU64,
pub stream_count: AtomicU64,
pub decoder_count: AtomicU64,
pub buffer_overflow_count: AtomicU64,
pub shutdown: CancellationToken,
pub restart_requested: AtomicBool,
}
impl AppState {
pub async fn load(paths: ConfigPaths) -> Result<Arc<Self>> {
let config = LoadedConfig::load(&paths).await?;
let integrity = channels_integrity(&config.channels)?;
let services = load_json_db(&paths.services_db, &integrity).await?;
let programs = load_json_db(&paths.programs_db, &integrity).await?;
let tuners = TunerManager::new(&config.tuners);
let job_schedules = initial_job_schedules(&config);
let max_running = config.server.job_max_running.unwrap_or_else(|| {
std::thread::available_parallelism()
.map_or(1, usize::from)
.saturating_div(2)
.clamp(1, 100)
});
let (event_sender, _) = broadcast::channel(EVENT_CAPACITY);
let (log_sender, _) = broadcast::channel(LOG_CAPACITY);
let now = Self::now_ms();
Ok(Arc::new(Self {
paths,
config: RwLock::new(config),
services: RwLock::new(services),
programs: RwLock::new(programs),
tuners,
jobs: RwLock::new(Vec::new()),
job_schedules: RwLock::new(job_schedules),
job_abort_tokens: Mutex::new(HashMap::new()),
job_semaphore: Arc::new(Semaphore::new(max_running)),
job_id_prefix: format!("{}.", to_base36(now)),
next_job_id: AtomicU64::new(0),
scan_status: RwLock::new(ChannelScanStatus::default()),
scan_cancel: Mutex::new(None),
gathering_networks: RwLock::new(HashSet::new()),
event_sender,
event_history: RwLock::new(VecDeque::new()),
log_sender,
log_history: RwLock::new(VecDeque::new()),
rpc_count: AtomicU64::new(0),
stream_count: AtomicU64::new(0),
decoder_count: AtomicU64::new(0),
buffer_overflow_count: AtomicU64::new(0),
shutdown: CancellationToken::new(),
restart_requested: AtomicBool::new(false),
}))
}
pub async fn log(&self, line: impl Into<String>) {
let line = line.into();
let max_history = self
.config
.read()
.await
.server
.max_log_history
.unwrap_or(1000);
{
let mut history = self.log_history.write().await;
history.push_back(line.clone());
while history.len() > max_history {
history.pop_front();
}
}
let _ = self.log_sender.send(line);
}
pub async fn emit_event<T>(&self, resource: EventResource, event_type: EventType, data: &T)
where
T: Serialize + ?Sized,
{
let Ok(data) = serde_json::to_value(data) else {
tracing::error!("failed to serialize event data");
return;
};
self.emit_event_value(resource, event_type, data).await;
}
pub async fn emit_event_value(
&self,
resource: EventResource,
event_type: EventType,
data: Value,
) {
let event = Event {
resource,
event_type,
data,
time: Self::now_ms(),
};
{
let mut history = self.event_history.write().await;
history.push_back(event.clone());
while history.len() > 100 {
history.pop_front();
}
}
let _ = self.event_sender.send(event);
}
#[must_use]
pub fn next_job_id(&self) -> String {
let counter = self.next_job_id.fetch_add(1, Ordering::Relaxed) + 1;
format!("{}{}", self.job_id_prefix, counter)
}
#[must_use]
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
})
}
#[must_use]
pub fn rpc_count(&self) -> u64 {
self.rpc_count.load(Ordering::Relaxed)
}
}
fn initial_job_schedules(config: &LoadedConfig) -> Vec<JobScheduleItem> {
let mut schedules = vec![
JobScheduleItem {
key: "Program.GC".into(),
schedule: config
.server
.program_gc_job_schedule
.clone()
.unwrap_or_else(|| "45 * * * *".into()),
job: JobReference {
key: "Program.GC".into(),
name: "Program GC".into(),
},
},
JobScheduleItem {
key: "Service.Updater".into(),
schedule: "5 6 * * *".into(),
job: JobReference {
key: "Service.Updater".into(),
name: "Service Updater".into(),
},
},
];
if config.server.disable_eit_parsing != Some(true) {
schedules.push(JobScheduleItem {
key: "EPG.Gatherer".into(),
schedule: config
.server
.epg_gathering_job_schedule
.clone()
.unwrap_or_else(|| "20,50 * * * *".into()),
job: JobReference {
key: "EPG.Gatherer".into(),
name: "EPG Gatherer".into(),
},
});
}
schedules
}
fn to_base36(mut value: u64) -> String {
const DIGITS: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
if value == 0 {
return "0".into();
}
let mut encoded = Vec::new();
while value > 0 {
encoded.push(DIGITS[(value % 36) as usize]);
value /= 36;
}
encoded.reverse();
encoded.into_iter().map(char::from).collect()
}
pub struct RpcConnectionGuard {
state: Arc<AppState>,
}
impl RpcConnectionGuard {
pub fn new(state: Arc<AppState>) -> Self {
state.rpc_count.fetch_add(1, Ordering::Relaxed);
Self { state }
}
}
impl Drop for RpcConnectionGuard {
fn drop(&mut self) {
self.state.rpc_count.fetch_sub(1, Ordering::Relaxed);
}
}
pub struct StreamGuard {
state: Arc<AppState>,
}
pub struct DecoderGuard {
state: Arc<AppState>,
}
impl DecoderGuard {
pub fn new(state: Arc<AppState>) -> Self {
state.decoder_count.fetch_add(1, Ordering::Relaxed);
Self { state }
}
}
impl Drop for DecoderGuard {
fn drop(&mut self) {
self.state.decoder_count.fetch_sub(1, Ordering::Relaxed);
}
}
impl StreamGuard {
pub fn new(state: Arc<AppState>) -> Self {
state.stream_count.fetch_add(1, Ordering::Relaxed);
Self { state }
}
}
impl Drop for StreamGuard {
fn drop(&mut self) {
self.state.stream_count.fetch_sub(1, Ordering::Relaxed);
}
}

View File

@@ -0,0 +1,13 @@
[package]
name = "mirakurun-types"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,808 @@
// Copyright 2026 Mirakurun contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub type ProgramId = u64;
pub type EventId = u16;
pub type ServiceId = u16;
pub type NetworkId = u16;
pub type ServiceItemId = u64;
pub type UnixTimeMs = u64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ChannelType {
#[serde(rename = "GR")]
Gr,
#[serde(rename = "BS")]
Bs,
#[serde(rename = "CS")]
Cs,
#[serde(rename = "SKY")]
Sky,
}
impl ChannelType {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Gr => "GR",
Self::Bs => "BS",
Self::Cs => "CS",
Self::Sky => "SKY",
}
}
}
impl std::str::FromStr for ChannelType {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"GR" => Ok(Self::Gr),
"BS" => Ok(Self::Bs),
"CS" => Ok(Self::Cs),
"SKY" => Ok(Self::Sky),
_ => Err(format!("unsupported channel type: {value}")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Channel {
#[serde(rename = "type")]
pub channel_type: ChannelType,
pub channel: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub services: Option<Vec<Service>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Service {
pub id: ServiceItemId,
pub service_id: ServiceId,
pub network_id: NetworkId,
pub name: String,
#[serde(rename = "type")]
pub service_type: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub logo_id: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_logo_data: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_control_key_id: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub epg_ready: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub epg_updated_at: Option<UnixTimeMs>,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel: Option<Box<Channel>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Program {
pub id: ProgramId,
pub event_id: EventId,
pub service_id: ServiceId,
pub network_id: NetworkId,
pub start_at: UnixTimeMs,
pub duration: u64,
pub is_free: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub genres: Option<Vec<ProgramGenre>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub video: Option<ProgramVideo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audios: Option<Vec<ProgramAudio>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub series: Option<ProgramSeries>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extended: Option<BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub related_items: Option<Vec<ProgramRelatedItem>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProgramGenre {
pub lv1: u8,
pub lv2: u8,
pub un1: u8,
pub un2: u8,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProgramVideo {
#[serde(rename = "type")]
pub video_type: ProgramVideoType,
pub resolution: ProgramVideoResolution,
pub stream_content: u8,
pub component_type: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProgramVideoType {
#[serde(rename = "mpeg2")]
Mpeg2,
#[serde(rename = "h.264")]
H264,
#[serde(rename = "h.265")]
H265,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProgramVideoResolution {
#[serde(rename = "240p")]
P240,
#[serde(rename = "480i")]
I480,
#[serde(rename = "480p")]
P480,
#[serde(rename = "720p")]
P720,
#[serde(rename = "1080i")]
I1080,
#[serde(rename = "1080p")]
P1080,
#[serde(rename = "2160p")]
P2160,
#[serde(rename = "4320p")]
P4320,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProgramAudio {
pub component_type: u8,
pub component_tag: u8,
pub is_main: bool,
pub sampling_rate: u32,
pub langs: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProgramSeries {
pub id: u16,
pub repeat: u8,
pub pattern: u8,
pub expires_at: i64,
pub episode: u16,
pub last_episode: u16,
pub name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProgramRelatedItemType {
Shared,
Relay,
Movement,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProgramRelatedItem {
#[serde(rename = "type")]
pub item_type: ProgramRelatedItemType,
#[serde(skip_serializing_if = "Option::is_none")]
pub network_id: Option<NetworkId>,
pub service_id: ServiceId,
pub event_id: EventId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(clippy::struct_excessive_bools)]
pub struct TunerDevice {
pub index: usize,
pub name: String,
pub types: Vec<ChannelType>,
pub command: Option<String>,
pub pid: Option<u32>,
pub users: Vec<TunerUser>,
pub is_available: bool,
pub is_remote: bool,
pub is_free: bool,
pub is_using: bool,
pub is_fault: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TunerUser {
pub id: String,
pub priority: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_decoder: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_setting: Option<StreamSetting>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_info: Option<BTreeMap<String, StreamPidInfo>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StreamSetting {
pub channel: ConfigChannel,
#[serde(skip_serializing_if = "Option::is_none")]
pub network_id: Option<NetworkId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_id: Option<ServiceId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub event_id: Option<EventId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub no_provide: Option<bool>,
#[serde(rename = "parseNIT", skip_serializing_if = "Option::is_none")]
pub parse_nit: Option<bool>,
#[serde(rename = "parseSDT", skip_serializing_if = "Option::is_none")]
pub parse_sdt: Option<bool>,
#[serde(rename = "parseEIT", skip_serializing_if = "Option::is_none")]
pub parse_eit: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct StreamPidInfo {
pub packet: u64,
pub drop: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct TunerProcess {
pub pid: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JobScheduleItem {
pub key: String,
pub schedule: String,
pub job: JobReference,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobReference {
pub key: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JobItem {
pub key: String,
pub name: String,
pub id: String,
pub status: JobStatus,
pub retry_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_rerunnable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_on_abort: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_on_fail: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_max: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_delay: Option<u64>,
pub is_aborting: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_aborted: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_skipped: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_failed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub created_at: UnixTimeMs,
pub updated_at: UnixTimeMs,
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<UnixTimeMs>,
#[serde(skip_serializing_if = "Option::is_none")]
pub finished_at: Option<UnixTimeMs>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JobStatus {
Queued,
Standby,
Running,
Finished,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Event {
pub resource: EventResource,
#[serde(rename = "type")]
pub event_type: EventType,
pub data: Value,
pub time: UnixTimeMs,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventResource {
Program,
Service,
Tuner,
Job,
JobSchedule,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EventType {
Create,
Update,
Remove,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigServer {
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
#[serde(rename = "disableIPv6", skip_serializing_if = "Option::is_none")]
pub disable_ipv6: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub log_level: Option<i8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_log_history: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub job_max_running: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub job_max_standby: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_buffer_bytes_before_ready: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub event_end_timeout: Option<u64>,
#[serde(
rename = "programGCJobSchedule",
skip_serializing_if = "Option::is_none"
)]
pub program_gc_job_schedule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub epg_gathering_job_schedule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub epg_retrieval_time: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logo_data_interval: Option<u64>,
#[serde(rename = "disableEITParsing", skip_serializing_if = "Option::is_none")]
pub disable_eit_parsing: Option<bool>,
#[serde(rename = "disableWebUI", skip_serializing_if = "Option::is_none")]
pub disable_web_ui: Option<bool>,
#[serde(rename = "allowIPv4CidrRanges", default = "default_ipv4_ranges")]
pub allow_ipv4_cidr_ranges: Vec<String>,
#[serde(rename = "allowIPv6CidrRanges", default = "default_ipv6_ranges")]
pub allow_ipv6_cidr_ranges: Vec<String>,
#[serde(default = "default_origins")]
pub allow_origins: Vec<String>,
#[serde(rename = "allowPNA", default = "default_allow_pna")]
pub allow_pna: bool,
#[serde(default = "default_tsplay_endpoint")]
pub tsplay_endpoint: String,
}
impl Default for ConfigServer {
fn default() -> Self {
Self {
path: Some("/var/run/mirakurun.sock".into()),
port: Some(40_772),
hostname: None,
disable_ipv6: None,
log_level: Some(2),
max_log_history: None,
job_max_running: None,
job_max_standby: None,
max_buffer_bytes_before_ready: None,
event_end_timeout: None,
program_gc_job_schedule: None,
epg_gathering_job_schedule: None,
epg_retrieval_time: None,
logo_data_interval: None,
disable_eit_parsing: None,
disable_web_ui: None,
allow_ipv4_cidr_ranges: default_ipv4_ranges(),
allow_ipv6_cidr_ranges: default_ipv6_ranges(),
allow_origins: default_origins(),
allow_pna: true,
tsplay_endpoint: default_tsplay_endpoint(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigTuner {
pub name: String,
pub types: Vec<ChannelType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dvb_device_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_mirakurun_host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_mirakurun_port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_mirakurun_decoder: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub decoder: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_disabled: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigChannel {
pub name: String,
#[serde(rename = "type")]
pub channel_type: ChannelType,
pub channel: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_id: Option<ServiceId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tsmf_rel_ts: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command_vars: Option<BTreeMap<String, CommandVariable>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_disabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub satelite: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub satellite: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub freq: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub polarity: Option<Polarity>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CommandVariable {
String(String),
Number(i64),
}
impl std::fmt::Display for CommandVariable {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::String(value) => formatter.write_str(value),
Self::Number(value) => value.fmt(formatter),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Polarity {
H,
V,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelScanStatus {
pub is_scanning: bool,
pub status: ChannelScanPhase,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub channel_type: Option<ChannelType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dry_run: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub progress: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_channel: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scan_log: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub takeover_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Vec<ConfigChannel>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_time: Option<UnixTimeMs>,
#[serde(skip_serializing_if = "Option::is_none")]
pub update_time: Option<UnixTimeMs>,
}
impl Default for ChannelScanStatus {
fn default() -> Self {
Self {
is_scanning: false,
status: ChannelScanPhase::NotStarted,
channel_type: None,
dry_run: None,
progress: None,
current_channel: None,
scan_log: None,
new_count: None,
takeover_count: None,
result: None,
start_time: None,
update_time: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChannelScanPhase {
NotStarted,
Scanning,
Completed,
Cancelled,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Version {
pub current: String,
pub latest: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Status {
pub time: UnixTimeMs,
pub version: String,
pub process: ProcessStatus,
pub epg: EpgStatus,
pub rpc_count: u64,
pub stream_count: StreamCount,
pub error_count: ErrorCount,
pub timer_accuracy: TimerAccuracy,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProcessStatus {
pub arch: String,
pub platform: String,
pub versions: BTreeMap<String, String>,
pub env: BTreeMap<String, String>,
pub pid: u32,
pub memory_usage: MemoryUsage,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MemoryUsage {
pub rss: u64,
pub heap_total: u64,
pub heap_used: u64,
pub external: u64,
pub array_buffers: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EpgStatus {
pub gathering_networks: Vec<NetworkId>,
pub stored_events: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StreamCount {
pub tuner_device: u64,
pub ts_filter: u64,
pub decoder: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ErrorCount {
pub uncaught_exception: u64,
pub unhandled_rejection: u64,
pub buffer_overflow: u64,
pub tuner_device_respawn: u64,
pub decoder_respawn: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TimerAccuracy {
pub last: f64,
pub m1: TimerSample,
pub m5: TimerSample,
pub m15: TimerSample,
}
impl Default for TimerAccuracy {
fn default() -> Self {
Self {
last: 0.0,
m1: TimerSample::default(),
m5: TimerSample::default(),
m15: TimerSample::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
pub struct TimerSample {
pub avg: f64,
pub min: f64,
pub max: f64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiError {
pub code: u16,
pub reason: Option<String>,
pub errors: Vec<OpenApiError>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenApiError {
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
}
const fn default_allow_pna() -> bool {
true
}
fn default_tsplay_endpoint() -> String {
"https://mirakurun-secure-contexts-api.pages.dev/tsplay/".into()
}
#[must_use]
pub fn default_ipv4_ranges() -> Vec<String> {
[
"10.0.0.0/8",
"127.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
]
.into_iter()
.map(String::from)
.collect()
}
#[must_use]
pub fn default_ipv6_ranges() -> Vec<String> {
vec!["fc00::/7".into()]
}
#[must_use]
pub fn default_origins() -> Vec<String> {
vec!["https://mirakurun-secure-contexts-api.pages.dev".into()]
}
#[must_use]
pub const fn service_item_id(network_id: NetworkId, service_id: ServiceId) -> ServiceItemId {
(network_id as u64) * 100_000 + service_id as u64
}
#[must_use]
pub const fn program_id(
network_id: NetworkId,
service_id: ServiceId,
event_id: EventId,
) -> ProgramId {
service_item_id(network_id, service_id) * 100_000 + event_id as u64
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
ChannelType, ConfigChannel, ConfigServer, StreamSetting, program_id, service_item_id,
};
#[test]
fn compound_ids_match_mirakurun_formula() {
assert_eq!(service_item_id(32_738, 1024), 3_273_801_024);
assert_eq!(program_id(32_738, 1024, 65_535), 327_380_102_465_535);
}
#[test]
fn channel_type_has_wire_value() {
assert_eq!(
serde_json::to_string(&ChannelType::Gr).expect("serialize enum"),
"\"GR\""
);
}
#[test]
fn config_server_applies_wire_defaults() {
let config: ConfigServer = serde_json::from_str("{}").expect("deserialize config");
assert!(config.allow_pna);
assert!(!config.allow_origins.is_empty());
}
#[test]
fn acronym_config_fields_match_node_wire_names() {
let config: ConfigServer = serde_json::from_value(json!({
"disableIPv6": true,
"programGCJobSchedule": "45 * * * *",
"disableEITParsing": true,
"disableWebUI": true,
"allowIPv4CidrRanges": ["127.0.0.0/8"],
"allowIPv6CidrRanges": ["::1/128"],
"allowPNA": false
}))
.expect("deserialize config");
assert_eq!(config.disable_ipv6, Some(true));
assert_eq!(
config.program_gc_job_schedule.as_deref(),
Some("45 * * * *")
);
assert_eq!(config.disable_eit_parsing, Some(true));
assert_eq!(config.disable_web_ui, Some(true));
assert!(!config.allow_pna);
let encoded = serde_json::to_value(config).expect("serialize config");
assert_eq!(encoded["disableIPv6"], true);
assert_eq!(encoded["programGCJobSchedule"], "45 * * * *");
assert_eq!(encoded["disableEITParsing"], true);
assert_eq!(encoded["disableWebUI"], true);
assert_eq!(encoded["allowPNA"], false);
assert!(encoded.get("disableIpv6").is_none());
}
#[test]
fn stream_parser_flags_keep_uppercase_acronyms() {
let setting = StreamSetting {
channel: ConfigChannel {
name: "test".into(),
channel_type: ChannelType::Gr,
channel: "27".into(),
service_id: None,
tsmf_rel_ts: None,
command_vars: None,
is_disabled: None,
satelite: None,
satellite: None,
space: None,
freq: None,
polarity: None,
},
network_id: None,
service_id: None,
event_id: None,
no_provide: None,
parse_nit: Some(true),
parse_sdt: Some(true),
parse_eit: Some(true),
};
let encoded = serde_json::to_value(setting).expect("serialize stream setting");
assert_eq!(encoded["parseNIT"], true);
assert_eq!(encoded["parseSDT"], true);
assert_eq!(encoded["parseEIT"], true);
}
}