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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
/target
/.idea
/.vscode
*.swp
*.tmp
node_modules/
web/dist/

32
AGENTS.md Normal file
View File

@@ -0,0 +1,32 @@
# AGENTS.md
このリポジトリは Mirakurun の Rust 実装です。
## 最優先
- 変更前に `/home/cyberrex/Mirakurun/development_status.md` と既存実装を確認する。
- Node.js 版 Mirakurun 4.1.3 の公開 API、設定、DB、ストリーム挙動との互換性を維持する。
- 不要なリファクタリングや整形変更をせず、既存の未コミット変更を巻き戻さない。
- Rust 版の実装、設計判断、検証、ブロッカー、予定変更が発生した場合は、同じ作業内で `/home/cyberrex/Mirakurun/development_status.md` を更新する。
- ステータスの完了項目には検証結果を伴わせ、未検証の項目を完了扱いにしない。
## Rust
- Rust 2024 Edition と `rust-version = "1.85"` を維持する。
- `cargo fmt --check``cargo clippy --workspace --all-targets --all-features -- -D warnings``cargo test --workspace --all-features`を基本検証とする。
- 本番コードで `unwrap``expect`、意図しない `panic!` を使用しない。
- Webハンドラー内でブロッキングI/Oを行わない。共有可変状態は用途ごとに分離し、ロックをawait境界を越えて保持しない。
- 公開型と永続化型ではSerdeのフィールド名、省略条件、整数幅を明示する。
## 構成
- `crates/mirakurun-types`: 公開API、設定、DBの型。
- `crates/mirakurun-core`: 設定、永続化、MPEG-TS/ARIB、チューナー、ジョブ。
- `crates/mirakurun-client`: TCP/Unixソケット対応クライアント。
- `crates/mirakurun-rs`: CLI、HTTP/WebSocketサーバー、Web UI。
## 互換性確認
- Node.js 版は `/home/cyberrex/Mirakurun` にある基準実装として扱う。
- API変更ではHTTPステータス、本文、nullと省略、ヘッダー、HEADの挙動を契約テストで確認する。
- TS処理変更では188バイト同期、PID、PAT/PMT、CRC、連続性カウンター、切断時の後処理を確認する。

1688
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

61
Cargo.toml Normal file
View File

@@ -0,0 +1,61 @@
[workspace]
members = [
"crates/mirakurun-client",
"crates/mirakurun-core",
"crates/mirakurun-rs",
"crates/mirakurun-types",
]
resolver = "2"
[workspace.package]
version = "4.1.3-rs.0"
edition = "2024"
rust-version = "1.85"
license = "Apache-2.0"
repository = "https://github.com/Chinachu/Mirakurun"
[workspace.dependencies]
anyhow = "1.0"
async-stream = "0.3"
axum = { version = "0.8", features = ["http1", "json", "tokio", "ws"] }
base64 = "0.22"
bytes = "1.10"
chrono = { version = "0.4.41", default-features = false, features = ["clock"] }
clap = { version = "4.5", features = ["derive", "env"] }
encoding_rs = "0.8"
futures-util = "0.3"
http = "1.3"
http-body-util = "0.1"
hyper = { version = "1.6", features = ["client", "http1"] }
hyper-util = { version = "0.1", features = ["tokio"] }
ipnet = { version = "2.11", features = ["serde"] }
libc = "0.2"
mime_guess = "2.0"
mpeg2ts-reader = "0.18.2"
nix = { version = "0.30", features = ["process", "signal"] }
rust-embed = "8.7"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml_ng = "0.10"
sha2 = "0.10"
tempfile = "3.20"
thiserror = "2.0"
tokio = { version = "1.45", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
tokio-stream = { version = "0.1", features = ["sync"] }
tokio-util = { version = "0.7", features = ["io", "rt"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "request-id", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[workspace.lints.rust]
unsafe_code = "warn"
[workspace.lints.clippy]
all = "warn"
pedantic = "warn"
[profile.release]
lto = "thin"
codegen-units = 1
strip = "symbols"

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
# mirakurun-rs
Mirakurun 4.1.3の実行時置換を目指す、Linux向けRust実装です。単一の
`mirakurun-rs` バイナリにHTTP/WebSocketサーバーとビルド済みReact UIを内包し、
本番実行時にNode.jsを必要としません。
現在は移行作業中です。チューナー共有、MPEG-TSサービス抽出、外部デコーダー、
EIT/SDTの基本解析、EPG・サービス更新ジョブ、チャンネルスキャン、REST API、
JSON-RPC、設定・DB、Web UIを実装しています。ただしARIB外字と一部記述子、
実機・差分・長時間試験は未完了です。本番切り替え前に
[`development_status.md`](../Mirakurun/development_status.md) の制約を確認してください。
## ビルドと検証
```console
cd web
npm ci
npm run typecheck
npm run build
cd ..
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features
cargo build --release --locked -p mirakurun-rs
```
サーバーはサブコマンドなし、または `serve` で起動します。
```console
cargo run -p mirakurun-rs -- --help
cargo run -p mirakurun-rs -- serve
```
ネイティブ導入手順は [doc/installation.md](doc/installation.md)、Node.js版からの
移行手順は [doc/migration.md](doc/migration.md) を参照してください。Dockerは
オプション扱いで、ネイティブ版の互換性完成後に追加します。

442
api.d.ts vendored Normal file
View File

@@ -0,0 +1,442 @@
/*
Copyright 2016 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export interface Error {
code?: number;
reason?: string;
errors?: ErrorOfOpenAPI[];
}
export interface ErrorOfOpenAPI {
errorCode?: string;
message?: string;
location?: string;
}
export type ProgramId = number;
export type EventId = number;
export type ServiceId = number;
export type NetworkId = number;
export type ServiceItemId = number;
export type UnixtimeMS = number;
export interface Channel {
type: ChannelType;
channel: string;
name?: string;
services?: Service[];
}
export type ChannelType = "GR" | "BS" | "CS" | "SKY";
export interface Service {
id: ServiceItemId;
serviceId: ServiceId;
networkId: NetworkId;
name: string;
type: number;
logoId?: number;
hasLogoData?: boolean;
remoteControlKeyId?: number;
epgReady?: boolean;
epgUpdatedAt?: number;
channel?: Channel;
}
export interface Program {
id: ProgramId;
eventId: EventId;
serviceId: ServiceId;
networkId: NetworkId;
startAt: UnixtimeMS;
duration: number;
isFree: boolean;
name?: string;
description?: string;
genres?: ProgramGenre[];
video?: ProgramVideo;
audios?: ProgramAudio[];
series?: ProgramSeries;
extended?: {
[description: string]: string;
};
relatedItems?: ProgramRelatedItem[];
}
export interface ProgramGenre {
lv1: number;
lv2: number;
un1: number;
un2: number;
}
export interface ProgramVideo {
type: ProgramVideoType;
resolution: ProgramVideoResolution;
streamContent: number;
componentType: number;
}
export type ProgramVideoType = "mpeg2" | "h.264" | "h.265";
export type ProgramVideoResolution = (
"240p" |
"480i" |
"480p" |
"720p" |
"1080i" |
"1080p" |
"2160p" |
"4320p"
);
export interface ProgramAudio {
/** component_type
* - 0x01 - 1/0 mode (single-mono)
* - 0x02 - 1/0 + 1/0 mode (dual-mono)
* - 0x03 - 2/0 mode (stereo)
* - 0x07 - 3/1 mode
* - 0x08 - 3/2 mode
* - 0x09 - 3/2 + LFE mode
*/
componentType: number;
componentTag: number;
isMain: boolean;
samplingRate: ProgramAudioSamplingRate;
/** ISO_639_language_code, ISO_639_language_code_2
* - this `#length` will `2` if dual-mono multi-lingual.
*/
langs: ProgramAudioLanguageCode[];
}
export type ProgramAudioSamplingRate = (
16000 |
22050 |
24000 |
32000 |
44100 |
48000
);
export type ProgramAudioLanguageCode = (
"jpn" |
"eng" |
"deu" |
"fra" |
"ita" |
"rus" |
"zho" |
"kor" |
"spa" |
"etc"
);
export interface ProgramSeries {
id: number;
repeat: number;
pattern: number;
expiresAt: number;
episode: number;
lastEpisode: number;
name: string;
}
export type ProgramRelatedItemType = "shared" | "relay" | "movement";
export interface ProgramRelatedItem {
type: ProgramRelatedItemType;
networkId?: number;
serviceId: number;
eventId: number;
}
export interface TunerDevice {
index: number;
name: string;
types: ChannelType[];
command: string;
pid: number;
users: TunerUser[];
isAvailable: boolean;
isRemote: boolean;
isFree: boolean;
isUsing: boolean;
isFault: boolean;
}
export interface TunerUser {
id: string;
priority: number;
agent?: string;
url?: string;
disableDecoder?: boolean;
streamSetting?: StreamSetting;
streamInfo?: StreamInfo;
}
interface StreamSetting {
channel: ConfigChannelsItem;
networkId?: number;
serviceId?: number;
eventId?: number;
noProvide?: boolean;
parseNIT?: boolean;
parseSDT?: boolean;
parseEIT?: boolean;
}
export interface StreamInfo {
[PID: string]: {
packet: number;
drop: number;
}
}
export interface TunerProcess {
pid: number;
}
export interface JobScheduleItem {
key: string;
schedule: string;
job: Pick<JobItem, "key" | "name">;
}
export interface JobItem {
key: string;
name: string;
id: string;
status: "queued" | "standby" | "running" | "finished";
retryCount: number;
isRerunnable?: boolean;
retryOnAbort?: boolean;
retryOnFail?: boolean;
retryMax?: number;
retryDelay?: number;
isAborting: boolean;
hasAborted?: boolean;
hasSkipped?: boolean;
hasFailed?: boolean;
error?: string;
createdAt: number;
updatedAt: number;
startedAt?: number;
finishedAt?: number;
duration?: number;
}
export interface Event<T = any> {
resource: EventResource;
type: EventType;
data: T;
time: UnixtimeMS;
}
export type EventResource = "program" | "service" | "tuner" | "job" | "job_schedule";
export type EventType = "create" | "update" | "remove";
export interface ConfigServer {
path?: string;
port?: number;
hostname?: string;
disableIPv6?: boolean;
logLevel?: LogLevel;
maxLogHistory?: number;
jobMaxRunning?: number;
jobMaxStandby?: number;
maxBufferBytesBeforeReady?: number;
eventEndTimeout?: number;
programGCJobSchedule?: string;
epgGatheringJobSchedule?: string;
epgRetrievalTime?: number;
logoDataInterval?: number;
disableEITParsing?: boolean;
disableWebUI?: boolean;
allowIPv4CidrRanges?: string[];
allowIPv6CidrRanges?: string[];
allowOrigins: string[];
allowPNA: boolean;
tsplayEndpoint: string;
}
/**
* FATAL: -1
* ERROR: 0
* WARN: 1
* INFO: 2
* DEBUG: 3
*/
export type LogLevel = -1 | 0 | 1 | 2 | 3;
export type ConfigTuners = ConfigTunersItem[];
export interface ConfigTunersItem {
/** tuner name for identifying. */
name: string;
/** channel type. */
types: ChannelType[];
/** [chardev][dvb] command to get TS. */
command?: string;
/** [dvb] dvr adapter device path */
dvbDevicePath?: string;
/** [remote] specify to use remote Mirakurun host like as `192.168.1.x`. */
remoteMirakurunHost?: string;
/** [remote] specify to use remote Mirakurun port number (default: 40772). */
remoteMirakurunPort?: number;
/** [remote] `true` to use remote decoder. `false` to use local decoder. (if decoder specified) */
remoteMirakurunDecoder?: boolean;
/** CAS processor command if needed. */
decoder?: string;
/** `true` to **disable** this tuner. */
isDisabled?: boolean;
}
export type ConfigChannels = ConfigChannelsItem[];
export interface ConfigChannelsItem {
name: string;
type: ChannelType;
/** passed to tuning command */
channel: string;
serviceId?: number;
/** TSMF (MPEG-TS Multi Frame) relative TS number config for CATV */
tsmfRelTs?: number;
/**
* passed to tuning command variables.
* @example { "freq": 123456, "polarity": "H", "space": 6, "extra-args": "..." }
*/
commandVars?: Record<string, string | number>;
isDisabled?: boolean;
/** @deprecated typo of "satellite". */
readonly satelite?: string;
/** @deprecated from 4.0.0, use `commandVars` instead. */
readonly satellite?: string;
/** @deprecated from 4.0.0, use `commandVars` instead. */
readonly space?: number;
/** @deprecated from 4.0.0, use `commandVars` instead. */
readonly freq?: number;
/** @deprecated from 4.0.0, use `commandVars` instead. */
readonly polarity?: "H" | "V";
}
export interface ChannelScanStatus {
isScanning: boolean;
status: ChannelScanPhase;
type?: ChannelType;
dryRun?: boolean;
progress?: number;
currentChannel?: string;
scanLog?: string[];
newCount?: number;
takeoverCount?: number;
result?: ConfigChannelsItem[];
startTime?: number;
updateTime?: number;
}
export type ChannelScanMode = "Channel" | "Service";
export type ChannelScanPhase = (
"not_started" |
"scanning" |
"completed" |
"cancelled" |
"error"
);
export type ChannelScanStep = (
"started" |
"scanning_channel" |
"takeover" |
"skipped" |
"services_found" |
"channels_found" |
"error"
);
export type ChannelScanResultType = (
"summary" |
"summary_new" |
"summary_takeover" |
"restart_required" |
"final_result"
);
export interface Version {
current: string;
latest: string;
}
export interface Status {
time: number;
version: string;
process: {
arch: string;
platform: string;
versions: any;
env: any;
pid: number;
memoryUsage: NodeJS.MemoryUsage;
};
epg: {
gatheringNetworks: NetworkId[];
storedEvents: number;
};
rpcCount: number;
streamCount: {
tunerDevice: number;
tsFilter: number;
decoder: number;
};
errorCount: {
uncaughtException: number;
unhandledRejection: number;
bufferOverflow: number;
tunerDeviceRespawn: number;
decoderRespawn: number;
};
timerAccuracy: {
last: number;
m1: {
avg: number;
min: number;
max: number;
};
m5: {
avg: number;
min: number;
max: number;
};
m15: {
avg: number;
min: number;
max: number;
};
};
}

1
config/channels.yml Normal file
View File

@@ -0,0 +1 @@
[]

10
config/server.yml Normal file
View File

@@ -0,0 +1,10 @@
logLevel: 2
path: /run/mirakurun/mirakurun.sock
port: 40772
allowIPv4CidrRanges:
- 10.0.0.0/8
- 127.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
allowIPv6CidrRanges:
- fc00::/7

1
config/tuners.yml Normal file
View File

@@ -0,0 +1 @@
[]

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);
}
}

31
dist/mirakurun-rs.service vendored Normal file
View File

@@ -0,0 +1,31 @@
[Unit]
Description=Mirakurun Rust DVR tuner server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=mirakurun
Group=video
RuntimeDirectory=mirakurun
StateDirectory=mirakurun
ConfigurationDirectory=mirakurun
Environment=SERVER_CONFIG_PATH=/etc/mirakurun/server.yml
Environment=TUNERS_CONFIG_PATH=/etc/mirakurun/tuners.yml
Environment=CHANNELS_CONFIG_PATH=/etc/mirakurun/channels.yml
Environment=SERVICES_DB_PATH=/var/lib/mirakurun/services.json
Environment=PROGRAMS_DB_PATH=/var/lib/mirakurun/programs.json
Environment=LOGO_DATA_DIR_PATH=/var/lib/mirakurun/logo-data
Environment=RUST_LOG=info
ExecStart=/usr/local/bin/mirakurun-rs serve
Restart=on-failure
RestartSec=3
TimeoutStopSec=15
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/run/mirakurun /var/lib/mirakurun
[Install]
WantedBy=multi-user.target

54
doc/installation.md Normal file
View File

@@ -0,0 +1,54 @@
# ネイティブ版のインストール
## ビルド
Rust 1.85以降と、Web UIを再ビルドする場合のみNode.js/npmを用意します。
```console
cd web
npm ci
npm run typecheck
npm run build
cd ..
cargo build --release --locked -p mirakurun-rs
```
`target/release/mirakurun-rs` はWeb UIを内包した実行ファイルです。本番実行時に
Node.jsやnpmは不要です。
## systemdで起動
以下は管理者権限で実行する例です。既存ファイルを上書きする前に退避してください。
```console
install -Dm755 target/release/mirakurun-rs /usr/local/bin/mirakurun-rs
install -Dm644 dist/mirakurun-rs.service /etc/systemd/system/mirakurun-rs.service
install -Dm644 config/server.yml /etc/mirakurun/server.yml
install -Dm644 config/tuners.yml /etc/mirakurun/tuners.yml
install -Dm644 config/channels.yml /etc/mirakurun/channels.yml
useradd --system --home-dir /var/lib/mirakurun --shell /usr/sbin/nologin \
--groups video mirakurun
systemctl daemon-reload
systemctl enable --now mirakurun-rs
```
チューナーデバイスに別のグループが必要な環境では、`mirakurun` ユーザーへその
補助グループを追加します。外部チューナー・デコーダーコマンドも、このユーザーで
実行できる必要があります。
## 手動起動
任意のディレクトリだけで試す場合は各パスを明示できます。
```console
./target/release/mirakurun-rs \
--server-config ./config/server.yml \
--tuners-config ./config/tuners.yml \
--channels-config ./config/channels.yml \
--services-db ./data/services.json \
--programs-db ./data/programs.json \
--logo-dir ./data/logo-data \
serve
```
Dockerは必須ではなく、現段階では配布対象に含めていません。

19
doc/migration.md Normal file
View File

@@ -0,0 +1,19 @@
# Node.js版からの移行
Rust版はNode.js版と同じYAML設定、JSONデータベース、ロゴデータを読み込む設計です。
移行前にNode.js版を停止し、設定とデータのバックアップを取得してください。同一の
実機チューナーへ両方を同時接続しないでください。
1. Node.js版の `server.yml``tuners.yml``channels.yml` をRust版の設定先へコピーする。
2. `services.json``programs.json``logo-data` をRust版のデータ先へコピーする。
3. 外部コマンドのパスと、実行ユーザーのデバイス権限を確認する。
4. Rust版だけを起動し、`/api/status``/api/services`、短時間のストリームを確認する。
5. 問題がある場合はRust版を停止し、退避した設定・データでNode.js版へ戻す。
設定を更新するとDBのチャンネル整合性値が変わります。移行時は設定とDBを同じ時点の
組として扱ってください。
ARIB EIT/SDTの基本解析、EPG・サービス更新、チャンネルスキャンは実装済みです。
ただしARIB DRCS・追加記号、一部の音声・シリーズ・関連番組記述子、実機での全放送波
試験は未完了です。受信地域とチューナーごとの比較試験を終えるまでは、本番環境への
切り替えを行わないでください。

4
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,4 @@
[toolchain]
channel = "1.85.0"
components = ["clippy", "rustfmt"]
profile = "minimal"

3335
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
web/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "mirakurun-rs-web",
"private": true,
"scripts": {
"build": "webpack --mode production",
"build:development": "webpack --mode development",
"typecheck": "tsc --project src/tsconfig.json --noEmit"
},
"dependencies": {
"@blueprintjs/core": "^5.17.6",
"@blueprintjs/icons": "^5.20.0",
"buffer": "^6.0.3",
"eventemitter3": "4.0.7",
"ip-num": "1.3.4",
"jsonrpc2-ws": "1.0.0-beta23",
"luxon": "^3.6.1",
"normalize.css": "^8.0.1",
"process": "^0.11.10",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-indiana-drag-scroll": "^2.2.1",
"react-router-dom": "7.18.2",
"sift": "15.1.3"
},
"devDependencies": {
"@types/luxon": "^3.6.2",
"@types/node": "22",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@types/ws": "^7.4.7",
"copy-webpack-plugin": "^14.0.0",
"css-loader": "5.2.7",
"sass": "^1.89.0",
"sass-loader": "^16.0.5",
"style-loader": "^2.0.0",
"ts-loader": "9.5.2",
"typescript": "5.7",
"webpack": "^5.107.0",
"webpack-cli": "^6.0.1"
},
"overrides": {
"uuid": "11.1.1"
}
}

View File

@@ -0,0 +1,33 @@
@use "~@blueprintjs/colors/lib/scss/colors"
.component-date-time-range
position: relative
display: inline-block
span.relative
opacity: 0.7
span.progress
position: absolute
display: block
left: 0
right: 0
bottom: 0
height: 16%
opacity: 0.7
background-color: rgba(colors.$black, 0.25)
span
position: absolute
top: 0
left: 0
bottom: 0
width: 0
transition: width 0.5s ease
background-color: colors.$orange4
.bp5-dark &
background-color: rgba(colors.$white, 0.25)
span
background-color: colors.$orange5

View File

@@ -0,0 +1,105 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useEffect, useState } from "react";
import { DateTime } from "luxon";
import { inRange } from "../modules/common";
import { clearSchedule, setSchedule } from "../modules/at";
import "./DateTimeRange.sass";
type DateTimeRangeProps = {
start: number;
end?: number;
};
export const DateTimeRange: React.FC<DateTimeRangeProps> = ({ start, end }) => {
console.debug("components", "DateTimeRange");
const [update, setUpdate] = useState(0);
const nowDate = DateTime.now();
const startDate = DateTime.fromMillis(start);
const endDate = end ? DateTime.fromMillis(end) : undefined;
const durationS = endDate ? endDate.diff(startDate, "seconds").seconds : 0;
const deltaS = nowDate.diff(startDate, "seconds").seconds;
const progress = end && inRange(nowDate, startDate, endDate) ? deltaS / durationS : undefined;
const relative = progress ? "放送中" : `@${startDate.toRelative({ style: "narrow" })}`;
useEffect(() => {
const schedules: ReturnType<typeof setSchedule>[] = [];
if (nowDate <= startDate) {
schedules.push(setSchedule(startDate.toMillis(), () => setUpdate(Date.now())));
}
if (nowDate <= endDate) {
schedules.push(setSchedule(endDate.toMillis(), () => setUpdate(Date.now())));
}
return () => {
for (const id of schedules) {
clearSchedule(id);
}
};
}, [start, end]);
useEffect(() => {
let ms = 10000;
if (!progress) {
const diff = Math.abs(deltaS);
if (diff < 15) {
ms = 1000;
} else if (diff < 30) {
ms = 5000;
} else if (diff < 60) {
ms = 10000;
} else if (diff < 60 * 2) {
ms = 20000;
} else if (diff < 60 * 5) {
ms = 30000;
} else if (diff < 60 * 60) {
ms = 60000;
} else {
ms = 180000;
}
}
const timeoutId = setTimeout(() => setUpdate(Date.now()), ms);
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [update]);
return (
<span className="component-date-time-range" title={startDate.toISO()}>
{startDate.toFormat("M/d (ccc) HH:mm")}
&nbsp;&nbsp;
{end && <>
{endDate.toFormat("HH:mm")}
&nbsp;({durationS / 60})
</>}
&nbsp;
<span className="relative">{relative}</span>
{progress && (
<span className="progress">
<span style={{ width: `${progress * 100}%` }} />
</span>
)}
</span>
);
};

View File

@@ -0,0 +1,333 @@
@use "sass:color"
@use "~@blueprintjs/colors/lib/scss/colors"
$header-height: 40px
$timescale-width: 24px
$block-width: 170px
$block-height: 240px
$timetable-border-color: colors.$gray5
.component-epg-table
position: absolute
top: 0
right: 0
bottom: 0
left: 0
background: colors.$light-gray2
.hide
opacity: 0
pointer-events: none
> *
position: absolute
> button.bp5-button
z-index: 2
backdrop-filter: blur(8px) brightness(1.1)
border-color: colors.$gray1 !important
transition: all 0.1s ease 0s
box-shadow: 0 0 0 1px rgba(colors.$white, 0.5) !important
color: colors.$black !important
.bp5-icon > svg:not([fill])
color: colors.$gray1 !important
&.jump-to-timeline
right: 25px
bottom: 25px
> .header
z-index: 1
top: 0
right: 0
left: 0
height: $header-height
white-space: nowrap
overflow: hidden
padding-left: $timescale-width
margin-left: 0px // for scroll
background: colors.$dark-gray5
.bp5-dark &
background: colors.$dark-gray1
.epg-table-header-item
vertical-align: top
display: inline-flex
align-items: center
overflow: hidden
font-size: 13px
font-weight: 500
line-height: $header-height
width: $block-width
height: $header-height
padding: 0 5px
color: colors.$light-gray4
&.date
font-weight: 600
.bp5-dark &
color: colors.$light-gray3
&:last-child
margin-right: 24px
&:not(.loading)
opacity: 0
animation: 0.4s ease 0.2s 1 normal forwards running fade-in
&:hover
background: color.adjust(colors.$dark-gray5, $lightness: -8%)
.bp5-dark &
background: color.adjust(colors.$dark-gray1, $lightness: 7%)
> img,
> div.img
width: 32px
height: 18px
margin-right: 5px
border-radius: 1px
filter: saturate(80%)
&:hover:not(.loading) > img
filter: none
> span
text-overflow: ellipsis
font-feature-settings: "palt" 1
overflow: hidden
&.bp5-skeleton
display: inline-block
width: 100px
height: 14px
> .timescale
z-index: 1
top: $header-height
right: 0
bottom: 0
left: 0
overflow: hidden
user-select: none
pointer-events: none
margin-top: 0px // for scroll
.timeline
position: absolute
top: -2px
right: 0
left: calc($timescale-width)
height: 2px
opacity: 0.5
pointer-events: all
box-shadow: 0 0 4px colors.$gray3
background: colors.$gray4
&.today
background: colors.$orange5
&,
> .clock
transition: all 0.4s ease 4s
&:hover,
&.show,
&:hover > .clock,
&.show > .clock
opacity: 1
transition: opacity 0.1s linear 0s
> .clock
position: absolute
top: -8px
left: 0
padding: 0 8px
line-height: 18px
font-size: 13px
font-weight: 500
background: inherit
opacity: 0
pointer-events: none
color: colors.$black
.timescale-item
height: $block-height
border-bottom: 1px dashed rgba($timetable-border-color, 0.5)
> div
width: $timescale-width
height: calc(100% + 1px)
padding-top: 6px
writing-mode: vertical-rl
text-orientation: sideways
font-size: 13px
font-weight: 400
letter-spacing: 0.1em
line-height: $timescale-width
pointer-events: all
border-bottom: 1px solid colors.$gray3
color: colors.$light-gray5
background: colors.$black
&:first-child > div
border-top: 1px solid colors.$gray3
// todo: to variables
&.hour-0 > div,
&.hour-1 > div,
&.hour-2 > div
background: rgb(0,51,127)
&.hour-3 > div,
&.hour-4 > div,
&.hour-5 > div
background: rgb(0,102,127)
&.hour-6 > div,
&.hour-7 > div,
&.hour-8 > div
background: rgb(0,127,102)
&.hour-9 > div,
&.hour-10 > div,
&.hour-11 > div
background: rgb(102,127,0)
&.hour-12 > div,
&.hour-13 > div,
&.hour-14 > div
background: rgb(127,102,0)
&.hour-15 > div,
&.hour-16 > div,
&.hour-17 > div
background: rgb(127,51,0)
&.hour-18 > div,
&.hour-19 > div,
&.hour-20 > div
background: rgb(127,0,102)
&.hour-21 > div,
&.hour-22 > div,
&.hour-23 > div
background: rgb(102,0,127)
> .timetable
display: flex
position: absolute
top: $header-height
left: $timescale-width
right: 0
bottom: 0
overflow: auto
> .bp5-spinner
position: absolute
top: calc(50% - 20px)
left: calc(50% - 20px)
opacity: 0
.bp5-spinner-track
stroke: rgba(95, 107, 124, 0.2)
.bp5-spinner-head
stroke: rgba(95, 107, 124, 0.8)
.timetable-col
position: relative
width: $block-width // for skeleton
flex-shrink: 0
overflow: hidden
border-right: 1px solid $timetable-border-color
background: colors.$light-gray3
opacity: 0
animation: 0.2s ease 0.1s 1 normal forwards running fade-in
button.timetable-cell
position: absolute
width: 100%
border: 0 transparent
text-align: left
padding: 0
overflow: hidden
border-bottom: 1px solid $timetable-border-color
background: #fff
color: colors.$dark-gray2
&.no-data
cursor: default
&:last-child
height: auto !important
bottom: 0
&:not(.no-data):hover
filter: brightness(0.97)
&.bp5-active
z-index: 2
box-shadow: inset 0 0 0 4px rgba(colors.$black, 0.15)
&.event-group-shared
color: colors.$blue2
&.event-group-shared,
&.no-data
opacity: 0.45
> div
position: absolute
top: 8px
right: 8px
bottom: 8px
left: 8px
line-height: 16px
font-size: 13px
word-break: break-all
overflow: hidden
time
margin-right: 4px
font-size: 10px
font-weight: 700
vertical-align: top
color: colors.$gray2
.description
margin-top: 4px
font-size: 12px
font-weight: 400
font-feature-settings: "palt" 1, "pwid" 1
line-height: 1.5
color: colors.$dark-gray5
.component-program-genres
margin-top: 4px
.caution
padding: 0
background: none
color: colors.$orange4
&.short > div
position: relative
top: auto
right: auto
bottom: auto
left: auto
margin: 0 8px
white-space: nowrap
font-size: 11px
&.x-short > div
top: 0
font-size: 10px
line-height: 10px
time
vertical-align: inherit
&.xx-short > div
> *
display: none
&.long > div
word-break: normal

View File

@@ -0,0 +1,654 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect, useRef } from "react";
import ScrollContainer from "react-indiana-drag-scroll";
import { Button, Spinner, NonIdealState } from "@blueprintjs/core";
import { DateTime } from "luxon";
import sift, { Query } from "sift";
import { LazyCaller } from "../modules/common";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import * as regexp from "../modules/regexp";
import { GenreUN2Map } from "../modules/constants";
import { Error, ChannelType, Service, Program, ProgramGenre } from "../../../api.d";
import { ProgramTitle } from "./ProgramTitle";
import { ProgramPopover } from "./ProgramPopover";
import { ProgramGenres } from "./ProgramGenres";
import "./EPGTable.sass";
const scrollState = {
left: {
GR: -1,
BS: -1,
CS: -1,
SKY: -1,
},
top: -1,
};
interface Dimensions {
// headerHeight: number;
// timescaleWidth: number;
timescaleHeight: number;
blockWidth: number;
// blockHeight: number;
scaleFactor: number;
}
type EPGTableProps = {
date: DateTime;
channelType?: ChannelType;
globalServiceId?: number;
defaultProgramId?: number;
defaultTime?: number;
};
export const EPGTable: React.FC<EPGTableProps> = ({ date, channelType, globalServiceId, defaultProgramId, defaultTime }) => {
console.debug("components", "EPGTable");
const startTime = date.toMillis();
const headerRef = useRef<HTMLDivElement>();
const timescaleRef = useRef<HTMLDivElement>();
const timelineRef = useRef<HTMLDivElement>();
const clockRef = useRef<HTMLDivElement>();
const timetableRef = useRef<HTMLDivElement>();
const headerItemRef = useRef<HTMLDivElement>();
const timescaleItemRef = useRef<HTMLDivElement>();
const jumpToTimelineRef = useRef<HTMLButtonElement>();
const [reload, setReload] = useState(0); // リロード用
const [dimensions, setDimensions] = useState<Dimensions>(null);
const [error, setError] = useState<Error>(null);
// null = loading, [] = empty
const [programId, setProgramId] = useState<number>(defaultProgramId || null);
const [time, setTime] = useState<number>(defaultTime || null);
const [services, setServices] = useState<Service[]>(null);
const [serviceItems, setServiceItems] = useState<JSX.Element[]>(null);
const [timetableCols, setTimetableCols] = useState<JSX.Element[]>(null);
if (globalServiceId) {
// 週間番組表
if (services) {
ui.setTitle(services[0]?.name);
} else if (error) {
ui.setTitle("エラー");
}
}
useEffect(() => {
const onUpdated = () => {
setReload(Date.now());
}
const onUpdatedLazy = new LazyCaller(0, 1000, onUpdated);
state.on("services", onUpdatedLazy.caller);
state.on("programs", onUpdatedLazy.caller);
state.subscribePrograms(true);
return () => {
state.off("services", onUpdatedLazy.caller);
state.off("programs", onUpdatedLazy.caller);
onUpdatedLazy.destroy();
}
}, []);
useEffect(() => {
return () => {
setError(null);
};
}, [state.location, reload]);
useEffect(() => {
return () => {
setServices(null);
setServiceItems(null);
};
}, [channelType]);
useEffect(() => {
return () => {
setTimetableCols(null);
};
}, [channelType]);
// 採寸
useEffect(() => {
const timescale = timescaleRef.current;
const headerItem = headerItemRef.current;
const timescaleItem = timescaleItemRef.current;
// 採寸
setDimensions({
// headerHeight: headerItem.offsetHeight,
// timescaleWidth: timescaleItem.offsetWidth,
timescaleHeight: timescale.scrollHeight,
blockWidth: headerItem.offsetWidth,
// blockHeight: timescaleItem.offsetHeight,
scaleFactor: timescaleItem.offsetHeight / 60,
});
}, []);
// スクロール連動・現在時刻・現在線の描画
useEffect(() => {
if (!dimensions) {
return;
}
const header = headerRef.current;
const timescale = timescaleRef.current;
const timeline = timelineRef.current;
const clock = clockRef.current;
const timetable = timetableRef.current;
const jumpToTimeline = jumpToTimelineRef.current;
if (!timetableCols) {
return;
}
const getPosition = () => Math.floor((Date.now() - state.todayTime) / 1000 / 60 * dimensions.scaleFactor);
// スクロール連動
const onScroll = () => {
scrollState.left[channelType] = header.scrollLeft = timetable.scrollLeft;
scrollState.top = timescale.scrollTop = timetable.scrollTop;
};
timetable.addEventListener("scroll", onScroll);
// 現在時刻を表示する
timeline.style.opacity = "";
let showClockTimeout: ReturnType<typeof setTimeout>;
const showClock = () => {
showClockTimeout = setTimeout(() => timeline.classList.remove("show"), 1000);
timeline.classList.add("show");
};
showClock();
// スクロール初期位置セット
timetable.scrollLeft = Math.round(
scrollState.left[channelType] > -1
? scrollState.left[channelType]
: 0
);
if (time) {
// スクロール時間指定
const position = Math.floor(time / 1000 / 60 * dimensions.scaleFactor);
timetable.scrollTop = Math.round(position - timetable.clientHeight / 4);
setTime(null);
} else {
// 現在時刻
timetable.scrollTop = Math.round(
scrollState.top > -1
? scrollState.top
: (getPosition() - timetable.clientHeight / 4)
);
}
// 現在線の描画
const updateTimeline = () => {
const position = getPosition();
const { scrollTop, clientHeight } = timetable;
// 色切り替え
if (state.todayTime === startTime) {
// 今日
timeline.classList.add("today");
} else {
// 今日じゃない
timeline.classList.remove("today");
}
// 現在線位置
timeline.style.top = `${position}px`;
// 時刻表示
const clockText = DateTime.now().toFormat("HH:mm");
if (clock.innerText !== clockText) {
clock.innerText = clockText;
showClock();
}
// ボタン表示
if (position > scrollTop && position < scrollTop + clientHeight) {
jumpToTimeline.classList.add("hide");
} else {
jumpToTimeline.classList.remove("hide");
}
};
const updateTimelineInterval = setInterval(updateTimeline, 1500);
updateTimeline();
return () => {
timetable.removeEventListener("scroll", onScroll);
clearInterval(updateTimelineInterval);
clearTimeout(showClockTimeout);
};
}, [startTime, timetableCols]);
// サービス一覧の取得
useEffect(() => {
if (!dimensions) {
return;
}
if (globalServiceId) {
// 週間番組表
const _service = state.services.find(s => s.id === globalServiceId);
if (!_service) {
setError({ code: 404, reason: "サービスが見つかりません" });
return;
}
setServices([_service]);
return;
}
// 全体番組表
const _services = state.services
.filter(s => s.type === 1)
.filter(s => channelType ? s.channel.type === channelType : true);
// ソート
_services.sort((a, b) => {
if (a.remoteControlKeyId && b.remoteControlKeyId) {
return a.remoteControlKeyId - b.remoteControlKeyId;
}
if (a.remoteControlKeyId && !b.remoteControlKeyId) {
return -1;
}
if (!a.remoteControlKeyId && b.remoteControlKeyId) {
return 1;
}
return a.id - b.id;
});
setServices(_services);
}, [channelType, dimensions, reload]);
// 番組一覧
useEffect(() => {
if (!services || services.length === 0) {
return;
}
console.debug("EPGTable", "services", services);
const query: Query<Program> = {
startAt: {
$gte: startTime - 60 * 60 * 2 * 1000,
$lt: startTime + 60 * 60 * 28 * 1000
}
};
if (channelType) {
query.serviceId = { $in: services.map(s => s.serviceId) };
} else if (globalServiceId) {
query.serviceId = services[0].serviceId;
query.startAt["$lt"] = startTime + 60 * 60 * 24 * 8 * 1000;
}
const filteredPrograms = state.programs.filter(sift(query));
console.debug("EPGTable", "filteredPrograms", filteredPrograms);
const programMap = new Map<string, Program>(); // イベントグループ検索用
const _serviceItems: JSX.Element[] = [];
const cols: JSX.Element[] = [];
// サービスごとにループ
for (let i = 0; i < services.length; i++) {
const service = services[i];
const servicePrograms: Program[] = [];
let count = 0;
// サービス絞り込み
for (const program of filteredPrograms) {
if (program.serviceId !== service.serviceId || program.networkId !== service.networkId) {
continue;
}
if (program.relatedItems?.filter(item => item.type === "shared").length !== 1) {
// オリジナルイベントのみをカウント
count++;
// イベントグループ被参照対象
programMap.set(`${program.serviceId}.${program.eventId}`, program);
}
servicePrograms.push(program);
}
if (count === 0) {
continue;
}
// ソート
servicePrograms.sort((a, b) => {
return a.startAt - b.startAt;
});
if (!globalServiceId) {
// 全体番組表
let className = "epg-table-header-item";
_serviceItems.push(
<button className={className} key={service.id}
onClick={() => {
state.navigate(`/epg/services/${service.id}?date=${date.toISODate()}`);
}}
>
{service.hasLogoData && <img src={`/api/services/${service.id}/logo`} />}
<span>{service.name}</span>
</button>
);
} else {
// 週間番組表
for (let i = 0; i < 8; i++) {
const cur = date.plus({ days: i });
_serviceItems.push(
<button className="epg-table-header-item date" key={`${service.id}-${i}`}
onClick={() => {
state.navigate(`/epg?type=${service.channel.type}&date=${cur.toISODate()}`);
}}
>
<span>{cur.toFormat("M月d日ccc")}</span>
</button>
);
}
}
// 放送終了ダミーデータ挿入
const last = servicePrograms[servicePrograms.length - 1];
servicePrograms.push({
id: last.id + 0.1,
eventId: last.eventId + 0.1,
serviceId: last.serviceId,
networkId: last.networkId,
startAt: last.startAt + last.duration,
duration: 60 * 15 * 1000,
isFree: false,
name: service.epgReady ? "(放送休止・未定)" : "(未受信)",
description: "no-data",
genres: [],
});
const cells: JSX.Element[] = [];
// 週間番組表用
const splitIndexes: number[] = [];
const maxHeight = 60 * 24 * dimensions.scaleFactor;
let topOffset = 0;
for (let i = 0; i < servicePrograms.length; i++) {
let program = { ...servicePrograms[i] };
let className = "timetable-cell";
const prev = servicePrograms[i - 1];
if (prev && (prev.startAt + prev.duration) !== program.startAt) {
// 放送未定ダミーデータ挿入
program = {
id: prev.id + 0.1,
eventId: prev.eventId + 0.1,
serviceId: prev.serviceId,
networkId: prev.networkId,
startAt: prev.startAt + prev.duration,
duration: program.startAt - (prev.startAt + prev.duration),
isFree: false,
name: service.epgReady ? "(放送休止・未定)" : "(未受信)",
description: "no-data",
genres: [],
};
servicePrograms.splice(i, 0, program);
}
if (program.description === "no-data") {
className += " no-data";
program.description = undefined;
}
const programStartTime = program.startAt;
const programStartDate = new Date(program.startAt);
let top = Math.floor((programStartTime - startTime) / 1000 / 60 * dimensions.scaleFactor) + topOffset;
let height = Math.floor(program.duration / 1000 / 60 * dimensions.scaleFactor);
if (globalServiceId) {
// 週間番組表用
if (top + height >= maxHeight) {
// 日付跨ぎ
splitIndexes.push(i + 1);
topOffset -= maxHeight;
// 日付の最後の番組を24時の位置に合わせる
height -= top + height - maxHeight;
// 分割
servicePrograms.splice(i, 0, {
...program,
});
}
}
if (top < 0) {
// 日付の最初の番組を0時の位置に合わせる
height += top;
top = 0;
}
const isShort = height <= 40;
if (isShort) {
className += " short";
if (height <= 16) {
className += " x-short";
}
if (height <= 10) {
className += " xx-short";
}
} else {
if (height >= 240) {
className += " long";
}
}
if (program.relatedItems && program.relatedItems.filter(item => item.type === "shared").length === 1) {
className += " event-group-shared";
const ref = programMap.get(`${program.relatedItems[0].serviceId}.${program.relatedItems[0].eventId}`)
if (ref) {
program.name = program.name || ref.name;
program.genres = program.genres || ref.genres;
program.description = program.description || "(イベント共有)";
}
}
const cautions: ProgramGenre[] = [];
if (program.genres && program.genres[0]) {
className += ` bg-genre-lv1-${program.genres[0].lv1}`;
for (const genre of program.genres) {
const un2Text = GenreUN2Map[(genre.lv1 * 0x1000) + (genre.lv2 * 0x100) + (genre.un1 * 0x10) + genre.un2];
if (un2Text) {
cautions.push(genre);
}
}
}
const defaultIsOpen = programId === program.id;
if (defaultIsOpen) {
setProgramId(null);
}
cells.push(
<ProgramPopover
key={`event-${program.eventId}-${program.startAt}`}
className={className}
program={program}
defaultIsOpen={defaultIsOpen}
renderTarget={({ isOpen, ...props }) => (
<button style={{ top, height }} {...props}>
<div>
<time dateTime={programStartDate.toISOString()}>{programStartDate.getMinutes()}</time>
<ProgramTitle program={program} />
{!isShort && program.description && (
<div className="description">{program.description.replace(regexp.enclosedAttributeUnicode, "")}</div>
)}
{!isShort && cautions.length > 0 && <ProgramGenres genres={cautions} />}
</div>
</button>
)}
/>
);
}
if (!globalServiceId) {
// 全体番組表
cols.push(
<div key={service.id}
className="timetable-col"
style={{ width: dimensions.blockWidth, height: dimensions.timescaleHeight }}
>
{cells}
</div>
);
} else {
// 週間番組表
for (let i = 0; i < splitIndexes.length; i++) {
cols.push(
<div key={`${service.id}-${i}`}
className="timetable-col"
style={{ width: dimensions.blockWidth, height: maxHeight }}
>
{cells.slice(splitIndexes[i - 1] || 0, splitIndexes[i] || cells.length)}
</div>
);
}
}
}
setServiceItems(_serviceItems);
setTimetableCols(cols);
}, [startTime, services]);
const timescaleDateShort = date.toFormat("M/d(ccc)");
const timescaleDateExtended = date.plus({ days: 1 }).toFormat("M/d(ccc)");
return (
<div className="component-epg-table">
<div className="header" ref={headerRef}>
{!serviceItems && !error && <>
<div className="epg-table-header-item loading" ref={headerItemRef}><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
</>}
{serviceItems}
</div>
<div className="timescale" ref={timescaleRef}>
<div className="timeline" ref={timelineRef}>
<div className="clock" ref={clockRef}>00:00</div>
</div>
<div className="timescale-item hour-0" ref={timescaleItemRef}><div>{timescaleDateShort} 0</div></div>
<div className="timescale-item hour-1"><div>1</div></div>
<div className="timescale-item hour-2"><div>2</div></div>
<div className="timescale-item hour-3"><div>{timescaleDateShort} 3</div></div>
<div className="timescale-item hour-4"><div>4</div></div>
<div className="timescale-item hour-5"><div>5</div></div>
<div className="timescale-item hour-6"><div>{timescaleDateShort} 6</div></div>
<div className="timescale-item hour-7"><div>7</div></div>
<div className="timescale-item hour-8"><div>8</div></div>
<div className="timescale-item hour-9"><div>{timescaleDateShort} 9</div></div>
<div className="timescale-item hour-10"><div>10</div></div>
<div className="timescale-item hour-11"><div>11</div></div>
<div className="timescale-item hour-12"><div>{timescaleDateShort} 12</div></div>
<div className="timescale-item hour-13"><div>13</div></div>
<div className="timescale-item hour-14"><div>14</div></div>
<div className="timescale-item hour-15"><div>{timescaleDateShort} 15</div></div>
<div className="timescale-item hour-16"><div>16</div></div>
<div className="timescale-item hour-17"><div>17</div></div>
<div className="timescale-item hour-18"><div>{timescaleDateShort} 18</div></div>
<div className="timescale-item hour-19"><div>19</div></div>
<div className="timescale-item hour-20"><div>20</div></div>
<div className="timescale-item hour-21"><div>{timescaleDateShort} 21</div></div>
<div className="timescale-item hour-22"><div>22</div></div>
<div className="timescale-item hour-23"><div>23</div></div>
<div className="timescale-item hour-0"><div>{timescaleDateExtended} 0 (24)</div></div>
<div className="timescale-item hour-1"><div>1 (25)</div></div>
<div className="timescale-item hour-2"><div>2 (26)</div></div>
<div className="timescale-item hour-3"><div>{timescaleDateExtended} 3 (27)</div></div>
{timetableCols && <div className="timescale-item reserve"><div></div></div>}
</div>
<Button variant="outlined" className="jump-to-timeline hide" ref={jumpToTimelineRef} icon="selection"
text="現在時刻へ"
onClick={() => {
ui.blur();
jumpToTimelineRef.current.classList.add("hide");
const { clientHeight } = timetableRef.current;
const { offsetTop } = timelineRef.current;
timetableRef.current.scrollTop = offsetTop - clientHeight / 4;
}}
/>
<ScrollContainer className="timetable" innerRef={timetableRef} hideScrollbars={false}
onClick={(a) => {
ui.blur();
}}
>
{!error && timetableCols === null
? <Spinner intent="none" size={40} />
: timetableCols
}
</ScrollContainer>
{(state.programs.length === 0 || state.services.length === 0) && !error && <>
<NonIdealState
icon={<Spinner />}
title="ロード中"
description="データを待機しています..."
/>
</> || services?.length === 0 && !error && <>
<NonIdealState
icon="satellite"
title="放送サービスなし"
description="指定された放送波のチャンネルが見つかりません"
/>
</> || timetableCols?.length === 0 && !error && <>
<NonIdealState
icon="satellite"
title="放送イベントなし"
description="指定された日付と放送波の番組情報が見つかりません"
/>
</>}
{error && <>
<NonIdealState
icon="warning-sign"
title={`${error.code} Error`}
description={error.reason || "エラーが発生しました"}
/>
</>}
</div>
);
};

View File

@@ -0,0 +1,82 @@
@use "~@blueprintjs/colors/lib/scss/colors"
@use "../vars"
.component-nav.bp5-navbar
> .bp5-navbar-group
> img.product-icon
width: 28px
height: 28px
margin-right: 10px
> .bp5-navbar-heading.product-name
font-size: 18px
font-weight: 300
> .version
font-size: 10px
font-weight: 400
top: -1em
color: vars.$theme-light-primary
.bp5-dark &
color: vars.$theme-dark-primary
> .bp5-input-group
.bp5-input
&:not(:hover,:focus)
box-shadow: none
> .bp5-button
span.badge
margin-left: 5px
font-size: 11px
color: vars.$theme-light-primary
.bp5-dark &
color: vars.$theme-dark-primary
> .bp5-button.active
border-bottom-right-radius: 0
border-bottom-left-radius: 0
box-shadow: 0 2px vars.$theme-light-primary
.bp5-dark &
box-shadow: 0 2px vars.$theme-dark-primary
//&:hover:not(.bp5-popover-target)
// background: none
// cursor: default
// responsive
.component-nav.bp5-navbar
> .bp5-navbar-group.bp5-align-left
.bp5-navbar-heading
@media (max-width: 800px)
display: none
.bp5-input-group
width: 200px
@media (max-width: 1000px)
width: 180px
@media (max-width: 450px)
width: 130px
> .bp5-navbar-group.bp5-align-right
@media (max-width: 950px)
button
.bp5-icon:not(:last-child)
margin: 0 -7px
.bp5-button-text
display: none
@media (max-width: 950px) and (min-width: 600px)
button:hover
.bp5-icon:not(:last-child)
margin: 0 7px 0 0
.bp5-button-text
display: block

169
web/src/components/Nav.tsx Normal file
View File

@@ -0,0 +1,169 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useEffect, useState, useCallback } from "react";
import { Alignment, Button, ButtonProps, Navbar, Menu, MenuItem, MenuDivider, Popover, PopoverTargetProps } from "@blueprintjs/core";
import { state } from "../modules/state";
import { useLocalStorageState } from "../hooks/useWebStorageState";
import { VersionStatus } from "./VersionStatus";
import { Restart } from "./Restart";
import "./Nav.sass";
type NavProps = {
pathLv1: string;
};
export const Nav: React.FC<NavProps> = ({ pathLv1 }) => {
console.debug("components", "Nav", pathLv1);
const { navigate, searchParams } = state;
const query = searchParams.get("q") || null;
const [icon, setIcon] = useState<string>(state.statusIconSrc);
useEffect(() => {
const onStatusIconKey = () => {
console.log("Nav", "onStatusIconKey", state.statusIconKey, state.statusIconSrc);
setIcon(state.statusIconSrc);
};
state.on("statusIconKey", onStatusIconKey);
return () => {
state.off("statusIconKey", onStatusIconKey);
};
}, []);
const [version, setVersion] = useState<string>(state.version);
useEffect(() => {
const onVersion = () => {
console.log("Nav", "onVersion", state.version);
setVersion(state.version);
};
state.on("version", onVersion);
return () => {
state.off("version", onVersion);
};
}, []);
const [dark, setDark] = useLocalStorageState<boolean>("dark", true);
useEffect(() => {
document.body.classList.toggle("bp5-dark", dark);
}, [dark]);
const getNavbarButtonProps = useCallback((name: string, className = "") => {
const props: ButtonProps = {
onClick: () => {
state.navigate("/" + name);
}
};
if (name === pathLv1) {
props.className = `${className} active`.trim();
}
return props;
}, [pathLv1]);
const [searchQuery, setSearchQuery] = useState<string>(query || "");
const executeSearch = useCallback(() => {
state.navigate(`/epg/search?q=${encodeURIComponent(searchQuery.trim())}`);
}, [searchQuery]);
const [runningJobs, setRunningJobs] = useState<number>(state.jobs.filter((job) => job.status === "running").length);
const [restartDialogOpen, setRestartDialogOpen] = useState<boolean>(false);
useEffect(() => {
const onJobs = () => {
setRunningJobs(state.jobs.filter((job) => job.status === "running").length);
};
state.on("jobs", onJobs);
return () => {
state.off("jobs", onJobs);
};
}, []);
return (
<Navbar className="component-nav">
<Navbar.Group align={Alignment.START}>
<img className="product-icon" src={icon} alt={state.statusName} />
<Navbar.Heading className="product-name">
Mirakurun
<sup className="version">{version}</sup>
</Navbar.Heading>
<div className="bp5-input-group">
<span className="bp5-icon bp5-icon-search"></span>
<input
type="text"
className="bp5-input"
placeholder="番組検索..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
executeSearch();
}
}}
/>
<Button
variant="minimal"
className="bp5-intent-primary"
icon="arrow-right"
title="検索"
onClick={executeSearch}
/>
</div>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
<Button variant="minimal" {...getNavbarButtonProps("")} icon="home" title="Home" text="Home" />
<Button variant="minimal" {...getNavbarButtonProps("epg")} icon="timeline-events" title="EPG" text="EPG" />
<Button variant="minimal" {...getNavbarButtonProps("jobs")} icon="ninja" title="ジョブ" text={
<>
{runningJobs !== 0 && <span className="badge">{runningJobs}</span>}
</>
} />
<Navbar.Divider />
<Button variant="minimal" {...getNavbarButtonProps("logs")} icon="pulse" title="ログ" />
<Popover
minimal
interactionKind="hover"
placement="bottom-end"
modifiers={{ offset: { enabled: true } }}
content={
<Menu>
{dark
? <MenuItem icon="flash" text="ライトテーマ" onClick={() => { setDark(false); }} />
: <MenuItem icon="moon" text="ダークテーマ" onClick={() => { setDark(true); }} />
}
<MenuDivider />
<MenuItem onClick={() => { state.navigate("/config/server"); }} icon="wrench" text="サーバー設定" />
<MenuItem onClick={() => { state.navigate("/config/tuners"); }} icon="wrench" text="チューナー設定" />
<MenuItem onClick={() => { state.navigate("/config/channels"); }} icon="wrench" text="チャンネル設定" />
<MenuDivider />
<MenuItem onClick={() => { window.open("/api/debug", "_blank"); }} icon="document" text="API Docs" />
<MenuDivider />
<MenuItem onClick={() => { state.navigate("/about"); }} icon="info-sign" textClassName="product-name" text={`Mirakurun ${version} について`} />
<VersionStatus asMenuItem />
<MenuItem icon="power" intent="danger" text="再起動..." onClick={() => setRestartDialogOpen(true)} />
</Menu>
}
renderTarget={({ isOpen, ref, ...props }: PopoverTargetProps) => (
<Button {...props} active={isOpen} ref={ref} variant="minimal" icon="cog" />
)}
/>
<Restart isOpen={restartDialogOpen} onClose={() => setRestartDialogOpen(false)} />
</Navbar.Group>
</Navbar>
);
};

View File

@@ -0,0 +1,23 @@
@use "~@blueprintjs/colors/lib/scss/colors"
.component-program-av-info
display: flex
flex-wrap: wrap
gap: 5px
font-size: 10px
font-weight: 600
color: colors.$gray1
span
padding: 2px 4px
border-radius: 2px
border: 1px solid
&.video
color: colors.$gold4
&.type
text-transform: uppercase
&.audio
color: colors.$vermilion4

View File

@@ -0,0 +1,63 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { langMap, audioModeMap } from "../modules/constants";
import { ProgramVideo, ProgramAudio } from "../../../api.d";;
import "./ProgramAVInfo.sass";
type ProgramAVInfoProps = {
video: ProgramVideo;
audios: ProgramAudio[];
};
export const ProgramAVInfo: React.FC<ProgramAVInfoProps> = ({ video, audios }) => {
// console.debug("components", "ProgramAVInfo");
const labels: JSX.Element[] = [];
if (video) {
if (video.type !== "mpeg2") {
labels.push(<span key="video.type" className="video type">{video.type}</span>);
}
labels.push(<span key="video.resolution" className="video resolution">{video.resolution}</span>);
}
if (audios) {
let count = 0;
for (const audio of audios) {
const trackPrefix = count === 0 ? "主" : "副";
const type8 = audio.componentType.toString(2).padStart(8, "0");
const mode = audioModeMap[type8.slice(-5)] || "不明なモード";
const lang = audio.langs.map(lang => langMap[lang]).join("");
labels.push(
<span key={`audios.${count}`} className="audio">
{audios.length > 1 && <>{trackPrefix}:&nbsp;</>}
{mode}
{lang !== "日本語" && <>&nbsp;/&nbsp;{lang}</>}
</span>
);
count++;
}
}
return (
<div className="component-program-av-info">
{labels}
</div>
);
};

View File

@@ -0,0 +1,44 @@
@use "~@blueprintjs/colors/lib/scss/colors"
.component-program-card-base
> div:not(:last-child):not(:first-child)
margin: 10px 0
.component-service-link
margin-bottom: 10px
p.title
margin-top: 0
.component-program-title
font-size: 14px
line-height: 17px
p.datetime
font-size: 12px
font-weight: 600
color: colors.$gray2
.bp5-dark &
color: colors.$gray4
p.description
font-size: 13px
font-feature-settings: "palt" 1
.bp5-dark &
color: colors.$light-gray2
.actions
margin-top: 15px
> div
display: flex
column-gap: 5px
margin-top: 10px
> a.more
flex-grow: 2
> button
width: 100%

View File

@@ -0,0 +1,120 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Button } from "@blueprintjs/core";
import { DateTime } from "luxon";
import { getGlobalServiceId } from "../modules/common";
import { setSchedule, clearSchedule } from "../modules/at";
import * as regexp from "../modules/regexp";
import { Program } from "../../../api.d";
import { ServiceLink } from "./ServiceLink";
import { ProgramTitle } from "./ProgramTitle";
import { DateTimeRange } from "./DateTimeRange";
import { ProgramGenres } from "./ProgramGenres";
import { ProgramAVInfo } from "./ProgramAVInfo";
import { WatchButton } from "./WatchButton";
import "./ProgramCardBase.sass";
type ProgramCardBaseProps = {
program: Program;
noAVInfo?: boolean;
noActions?: boolean;
} & React.HTMLAttributes<HTMLDivElement>;
export const ProgramCardBase: React.FC<ProgramCardBaseProps> = ({ program, noAVInfo, noActions, ...props }) => {
console.debug("components", "ProgramCardBase", program);
const now = Date.now();
const endAt = program.startAt + program.duration;
const isDummy = !Number.isInteger(program.id);
const date = DateTime.fromMillis(program.startAt).set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
const time = DateTime.fromMillis(program.startAt).diff(date).toMillis();
const timeForServiceLink = (time > (1000 * 60 * 60 * 24 - 1000 * 60 * 5)) ? 1 : time;
const [isOnAir, setIsOnAir] = useState(!isDummy && program.startAt <= now && endAt >= now);
useEffect(() => {
if (isDummy || noActions) {
return;
}
const schedules: ReturnType<typeof setSchedule>[] = [];
if (now <= program.startAt) {
schedules.push(setSchedule(program.startAt, () => setIsOnAir(true)));
}
if (now <= endAt) {
schedules.push(setSchedule(endAt, () => setIsOnAir(false)));
}
return () => {
for (const id of schedules) {
clearSchedule(id);
}
};
}, [program]);
return (
<div className="component-program-card-base" {...props}>
<ServiceLink
globalId={getGlobalServiceId(program.networkId, program.serviceId)}
date={date.toISODate()}
time={timeForServiceLink}
/>
<p className="title">
{noActions && (
<Link to={`/epg/programs/${program.id}`}>
<ProgramTitle program={program} />
</Link>
)}
{!noActions && (
<ProgramTitle program={program} />
)}
</p>
<p className="datetime">
<DateTimeRange start={program.startAt} end={endAt} />
</p>
{program.description && <p className="description">{program.description.replace(regexp.enclosedAttributeUnicode, "")}</p>}
{program.genres?.length > 0 && (
<ProgramGenres genres={program.genres} />
)}
{!noAVInfo && (program.video || program.audios) && (
<ProgramAVInfo video={program.video} audios={program.audios} />
)}
{!noActions && !isDummy && (
<div className="actions">
<div>
<Link className="more" to={`/epg/programs/${program.id}`}>
<Button variant="outlined" intent="primary" icon="arrow-right" text="番組詳細" />
</Link>
{(isOnAir) && (
<WatchButton variant="outlined" popoverPlacement="top-start" globalServiceId={getGlobalServiceId(program.networkId, program.serviceId)} />
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,20 @@
@use "~@blueprintjs/colors/lib/scss/colors"
@use "../vars"
.component-program-genres
display: flex
flex-wrap: wrap
gap: 5px
font-size: 11px
font-weight: 600
font-feature-settings: "palt" 1
color: #000
span
padding: 2px 4px
border-radius: 2px
filter: vars.$invert-filter
&.caution
filter: none
background: colors.$orange5

View File

@@ -0,0 +1,60 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { Genre1Map, Genre2Map, GenreUN1Map, GenreUN2Map } from "../modules/constants";
import { ProgramGenre } from "../../../api.d";
import "./ProgramGenres.sass";
type ProgramGenresProps = {
genres: ProgramGenre[];
};
export const ProgramGenres: React.FC<ProgramGenresProps> = ({ genres }) => {
// console.debug("components", "ProgramGenres");
const lv1Set = new Set<number>();
const labels: JSX.Element[] = [];
for (const genre of genres) {
const lv1Text = Genre1Map[genre.lv1];
if (!lv1Text) {
continue;
}
const un2Text = GenreUN2Map[(genre.lv1 * 0x1000) + (genre.lv2 * 0x100) + (genre.un1 * 0x10) + genre.un2];
if (un2Text) {
const key = (genre.lv1 * 0x1000) + (genre.lv2 * 0x100) + (genre.un1 * 0x10) + genre.un2;
if (key < 0xE000 || key > 0xE020) {
continue;
}
labels.push(<span key={key} className="caution">{un2Text}</span>);
continue;
}
if (!lv1Set.has(genre.lv1)) {
lv1Set.add(genre.lv1);
labels.push(<span key={genre.lv1} className={`bg-genre-lv1-${genre.lv1}`}>{lv1Text}</span>);
}
const lv2Text = Genre2Map[(genre.lv1 * 0x10) + genre.lv2];
labels.push(<span key={`${genre.lv1}.${genre.lv2}`} className={`bg-genre-lv1-${genre.lv1}`}>{lv2Text}</span>);
}
return (
<div className="component-program-genres">
{labels}
</div>
);
};

View File

@@ -0,0 +1,4 @@
.component-program-popover
padding: 15px
min-width: 300px
max-width: 380px

View File

@@ -0,0 +1,70 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useEffect, useState } from "react";
import { Popover, PopoverTargetProps } from "@blueprintjs/core";
import { Program } from "../../../api.d";
import { ProgramCardBase } from "./ProgramCardBase";
import "./ProgramPopover.sass";
type ProgramPopoverProps<T = {}> = {
program: Program;
key?: string;
className?: string;
portalContainer?: HTMLElement;
defaultIsOpen?: boolean;
renderTarget: (props: PopoverTargetProps & T) => JSX.Element;
};
export const ProgramPopover: React.FC<ProgramPopoverProps> = ({ program, renderTarget, className = "", defaultIsOpen = false, ...props }) => {
// console.debug("components", "ProgramPopover");
const [active, setActive] = useState(defaultIsOpen);
const [content, setContent] = useState<React.JSX.Element>(null);
useEffect(() => {
if (!active) {
return;
}
setContent(<ProgramCardBase program={program} />);
return () => {
setContent(null);
};
}, [active]);
if (className) {
className += " ";
}
className += "bp5-dark";
return (
<Popover {...props}
className={className}
renderTarget={renderTarget}
defaultIsOpen={active}
onOpening={() => setActive(true)}
onClosed={() => setActive(false)}
content={
<div className="component-program-popover">
{content}
</div>
}
/>
);
};

View File

@@ -0,0 +1,16 @@
@use "~@blueprintjs/colors/lib/scss/colors"
.component-program-related-links
display: flex
gap: 10px
> .bp5-section
min-width: 300px
max-width: 380px
background-color: colors.$light-gray4
.bp5-dark &
background-color: colors.$dark-gray4
.component-program-card-base
padding: 15px

View File

@@ -0,0 +1,104 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect, useMemo } from "react";
import { Section } from "@blueprintjs/core";
import { relatedItemTypeMap, relatedItemTypeIconMap } from "../modules/constants";
import { state } from "../modules/state";
import { Program } from "../../../api.d";
import { ProgramCardBase } from "./ProgramCardBase";
import "./ProgramRelatedLinks.sass";
type ProgramRelatedLinksProps = {
program: Program;
};
export const ProgramRelatedLinks: React.FC<ProgramRelatedLinksProps> = ({ program }) => {
console.debug("components", "ProgramRelatedLinks");
const [links, setLinks] = useState<React.JSX.Element[]>([]);
const relatedItems = useMemo(() => {
return program?.relatedItems?.filter(item => {
if (item.networkId) {
return item.eventId !== program.eventId || item.serviceId !== program.serviceId || item.networkId !== program.networkId;
}
return item.eventId !== program.eventId || item.serviceId !== program.serviceId;
});
}, [program]);
useEffect(() => {
if (!relatedItems || relatedItems.length === 0) {
setLinks([]);
return;
}
console.debug("ProgramRelatedLinks", "relatedItems", relatedItems);
let abort = false;
(async () => {
const _links: React.JSX.Element[] = [];
const programs = state.programs.length > 0 ? state.programs : await state.fetchPrograms();
if (abort) {
return;
}
for (const item of relatedItems) {
const p = programs.find(p => {
if (item.networkId) {
return p.eventId === item.eventId && p.serviceId === item.serviceId && p.networkId === item.networkId;
}
return p.eventId === item.eventId && p.serviceId === item.serviceId;
});
if (!p) {
continue;
}
const link = (
<Section
key={`${item.type}-${item.eventId}-${item.serviceId}`}
className={`related-item-type-${item.type}`}
icon={relatedItemTypeIconMap[item.type]}
title={relatedItemTypeMap[item.type]}
compact
>
<ProgramCardBase program={p} />
</Section>
);
_links.push(link);
}
setLinks(_links);
})();
return () => {
abort = true;
}
}, [relatedItems]);
if (links.length === 0) {
return <></>;
}
return (
<div className="component-program-related-links">
{links}
</div>
);
};

View File

@@ -0,0 +1,38 @@
@use "~@blueprintjs/colors/lib/scss/colors"
.component-program-title
font-size: inherit
line-height: inherit
.attribute,
.name,
.bp5-icon
margin-right: 2px
.bp5-alert-body &
margin-right: 2px
.name
font-weight: 600
font-feature-settings: "palt" 1
.attribute
border-radius: 1px
padding: 0 1px
font-size: 80%
font-weight: 500
vertical-align: 1px
background: colors.$gray2
color: #fff
.bp5-icon
vertical-align: -10%
&-tick
color: colors.$green4
&-flag
color: colors.$red4
&-record
color: colors.$vermilion4
&-small-cross
color: colors.$gray3

View File

@@ -0,0 +1,101 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useMemo } from "react";
import * as regexp from "../modules/regexp";
import { ProgramAttributeMap } from "../modules/constants";;
import { Program } from "../../../api.d";
import "./ProgramTitle.sass";
type ProgramTitleProps = {
program: Program;
};
export const ProgramTitle: React.FC<ProgramTitleProps> = ({ program }) => {
// console.debug("components", "ProgramTitle");
let name = program.name;
if (!name) {
if (program.relatedItems) {
const isShared = program.relatedItems.some(item => item.type === "shared");
if (isShared) {
name = "(イベント共有)";
} else {
name = "(不明)";
}
} else {
name = "(未定)";
}
}
name = name.replace(regexp.squaredUnicode, "").replace(regexp.legacyAttributeFormat, "");
const attributes = useMemo(() => {
const attrSet = new Set<keyof typeof ProgramAttributeMap>();
const attributeSource = (program.name || "") + (program.description || "");
if (attributeSource) {
const items = [
...attributeSource.match(regexp.enclosedAttributeUnicode) || [],
...attributeSource.match(regexp.legacyAttributeFormat) || [],
];
if (program.networkId >= 0x01 && program.networkId <= 0x0C && program.isFree) {
items.push("無");
}
for (const item of items) {
const attrKey = item.replace(/[\[\]()]/g, "").normalize("NFKC");
if (ProgramAttributeMap[attrKey]) {
attrSet.add(attrKey as any);
}
}
}
return [...attrSet];
}, [program.name, program.description]);
const labels = useMemo(() => {
const pre: JSX.Element[] = [];
const post: JSX.Element[] = [];
for (const attribute of attributes) {
/* if (attribute === "無") {
// 公共放送と無料放送の [無] は省略
continue;
} */
const label = (
<span key={`attribute-${attribute}`}
className={`attribute bg-attribute-${attribute}`}
title={ProgramAttributeMap[attribute]}>
{attribute}
</span>
);
if (["新", "再", "終", "生"].includes(attribute)) {
pre.push(label);
} else {
post.push(label);
}
}
return { pre, post };
}, [attributes]);
return (
<span className="component-program-title">
{labels.pre}
<span className="name" title={program.name}>{name}</span>
{labels.post}
</span>
);
};

View File

@@ -0,0 +1,50 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { Button, Dialog, DialogBody, DialogFooter } from "@blueprintjs/core";
export const Restart: React.FC<{
isOpen: boolean;
onClose: () => void;
}> = ({ isOpen, onClose }) => {
const handleRestart = async () => {
await fetch("/api/restart", { method: "PUT" });
onClose();
};
return (
<Dialog
isOpen={isOpen}
onClose={onClose}
title="Restart Mirakurun"
canEscapeKeyClose
>
<DialogBody>
<div>
Do you want to restart Mirakurun?
</div>
</DialogBody>
<DialogFooter
actions={
<>
<Button text="Cancel" onClick={onClose} />
<Button text="Restart" intent="danger" onClick={handleRestart} />
</>
}
/>
</Dialog>
);
};

View File

@@ -0,0 +1,8 @@
.component-service-link
display: flex
gap: 10px
align-items: center
> img
max-height: 18px
border-radius: 1px

View File

@@ -0,0 +1,73 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { channelTypeMap } from "../modules/constants";
import { Service } from "../../../api.d";
import { state } from "../modules/state";
import "./ServiceLink.sass";
type ServiceLinkProps = {
globalId: number;
date?: string;
time?: number;
} & React.HTMLAttributes<HTMLDivElement>;
export const ServiceLink: React.FC<ServiceLinkProps> = ({ globalId, date, time, ...props }) => {
console.debug("components", "ServiceLink");
const [service, setService] = useState<Service>(null);
useEffect(() => {
(async () => {
const _service = state.services.find(s => s.id === globalId);
setService(_service);
})();
return () => {
setService(null);
};
}, [globalId]);
let to = "#";
let className = "component-service-link";
if (props.className) {
className += ` ${props.className}`;
}
if (service) {
to = `/epg/services/${service.id}`;
if (date && time) {
to += `?date=${date}&time=${time}`
} else if (time) {
to += `?time=${time}`
} else if (date) {
to += `?date=${date}`
}
}
return (
<div className={className} {...props}>
{service && service.hasLogoData && <img src={`/api/services/${service.id}/logo`} />}
<Link className={service ? null : "bp5-skeleton"} title="EPG 番組表 (週間)" to={to}>
{service ? `${service.name.normalize("NFKC")} (${channelTypeMap[service.channel.type]})` : "サービス名..."}
</Link>
</div>
);
};

View File

@@ -0,0 +1,127 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useEffect, useState } from "react";
import { MenuItem } from "@blueprintjs/core";
import * as semver from "semver";
import { state } from "../modules/state";
interface VersionInfo {
current: string;
latest: string;
}
let cachedVersion: VersionInfo | null = null;
let isFetching = false;
const fetchListeners: Array<(version: VersionInfo | null) => void> = [];
const fetchVersion = async (): Promise<VersionInfo | null> => {
if (cachedVersion) {
return cachedVersion;
}
if (isFetching) {
return new Promise((resolve) => {
fetchListeners.push(resolve);
});
}
isFetching = true;
try {
const res = await fetch("/api/version");
if (res.ok) {
const data: VersionInfo = await res.json();
cachedVersion = data;
const listeners = [...fetchListeners];
fetchListeners.length = 0;
listeners.forEach((resolve) => resolve(data));
return data;
}
} catch (e) {
console.error("Failed to fetch version", e);
}
isFetching = false;
const listeners = [...fetchListeners];
fetchListeners.length = 0;
listeners.forEach((resolve) => resolve(null));
return null;
};
export const VersionStatus: React.FC<{
asMenuItem?: boolean;
}> = ({ asMenuItem = false }) => {
const [version, setVersion] = useState<VersionInfo | null>(cachedVersion);
const [loading, setLoading] = useState<boolean>(!cachedVersion);
useEffect(() => {
let isMounted = true;
if (!cachedVersion) {
fetchVersion().then((data) => {
if (isMounted) {
setVersion(data);
setLoading(false);
}
});
} else {
setLoading(false);
}
return () => {
isMounted = false;
};
}, []);
const hasUpdate = version
&& semver.valid(version.current)
&& semver.valid(version.latest)
&& semver.gt(version.latest, version.current);
if (asMenuItem) {
if (loading) {
return <MenuItem icon="updated" text="アップデートを確認中..." disabled />;
}
if (!version) {
return <MenuItem icon="updated" text="最新版を実行中です" disabled />;
}
if (hasUpdate) {
return (
<MenuItem
icon="updated"
intent="primary"
text={`最新版 (v${version.latest}) が利用可能です`}
onClick={() => {
state.navigate("/about");
}}
/>
);
}
return <MenuItem icon="updated" text="最新版を実行中です" disabled />;
}
if (loading) {
return <span>...</span>;
}
if (!version) {
return <span> ()</span>;
}
if (hasUpdate) {
return (
<span style={{ color: "#2d72d9", fontWeight: "bold" }}>
{version.latest} ()
</span>
);
}
return <span>{version.latest} ()</span>;
};

View File

@@ -0,0 +1,126 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import { Button, ButtonGroup, ButtonProps, Menu, MenuItem, Popover, PopoverTargetProps, Placement } from "@blueprintjs/core";
import { copyToClipboard } from "../modules/common";
import { state } from "../modules/state";
type WatchButtonProps = {
globalServiceId: number;
popoverPlacement?: Placement;
} & ButtonProps & React.HTMLAttributes<HTMLButtonElement>;
export const WatchButton: React.FC<WatchButtonProps> = ({ globalServiceId, popoverPlacement, ...props }) => {
console.debug("components", "WatchButton");
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
if (state.serverConfig) {
setLoading(false);
return;
}
await state.fetchServerConfig();
if (state.serverConfig) {
setLoading(false);
}
})();
}, []);
const tsplayDisabled = !state.serverConfig?.tsplayEndpoint || !state.serverConfig?.allowPNA;
const streamEndpoint = `${location.protocol}//${location.host}/api/services/${globalServiceId}/stream`;
return (<>
<ButtonGroup className={loading ? "bp5-skeleton" : ""}>
<Button {...props}
text="視聴テスト"
icon="play"
endIcon="lab-test"
disabled={tsplayDisabled}
onPointerUp={e => {
if (tsplayDisabled) {
return;
}
// e.preventDefault();
// e.stopPropagation();
// マウス中クリックか ctrl 押しながら左クリックか判定
const isMiddleButton = e.button === 1 || (e.button === 0 && e.ctrlKey);
let features = "noreferrer";
if (isMiddleButton) {
// features += "";
} else {
const width = 1280;
const height = 770;
// winPosX, winPosY は現在のブラウザウィンドウの画面上の真ん中に設定
const top = window.screenTop + (window.innerHeight / 2) - (height / 2);
const left = window.screenLeft + (window.innerWidth / 2) - (width / 2);
features += `,popup,width=${width},height=${height},top=${top},left=${left},resizable=yes`;
}
const openUrl = `${state.serverConfig.tsplayEndpoint}#${streamEndpoint}`;
window.open(openUrl, `_blank`, features);
}}
/>
<Popover
hasBackdrop={true}
onClose={e => {
e.preventDefault();
e.stopPropagation();
}}
captureDismiss={true}
placement={popoverPlacement}
positioningStrategy="absolute"
content={
<Menu>
<MenuItem icon="clipboard" text="URL をクリップボードにコピー" onClick={() => {
copyToClipboard(streamEndpoint);
}} />
<MenuItem icon="desktop" text="M3U プレイリスト..." onClick={() => {
const m3u8Content = (
`#EXTM3U\n` +
`#EXTINF:-1,\n` +
`${streamEndpoint}\n`
);
const blob = new Blob([m3u8Content], { type: "application/x-mpegURL" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Mirakurun_service_${globalServiceId}.m3u8`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
}} />
</Menu>
}
renderTarget={({ isOpen, ref, ...targetProps }: PopoverTargetProps) => (
<Button {...{...props, text: ""}} {...targetProps} active={isOpen} ref={ref} icon="more" title="再生方法..." />
)}
/>
</ButtonGroup>
</>);
};

4
web/src/custom.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*.svg' {
const content: string;
export default content;
}

View File

@@ -0,0 +1,97 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useState, useCallback } from "react";
import equal from "fast-deep-equal";
export function useLocalStorageState<T>(key: string, initState: T): [T, (newState: T) => void] {
key = "mirakurun:state:" + key;
const [stored, setStored] = useState<T>();
if (stored === undefined) {
const storedState = localStorage.getItem(key);
if (storedState !== null) {
initState = JSON.parse(storedState) as T;
}
setStored(initState);
// debug
console.debug("hooks", "useLocalStorageState()", "get", key, initState);
}
const [state, setState] = useState(initState);
const setStorageState = useCallback((newState: T) => {
if (equal(newState, state)) {
return;
}
if (newState === undefined) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, JSON.stringify(newState));
}
setState(newState);
setStored(newState);
// debug
console.debug("hooks", "useLocalStorageState()", "set", key, newState);
}, [state]);
return [state, setStorageState];
}
export function useSessionStorageState<T>(key: string, initState: T): [T, (newState: T) => void] {
key = "mirakurun:state:" + key;
const [stored, setStored] = useState<T>();
if (stored === undefined) {
const storedState = sessionStorage.getItem(key);
if (storedState !== null) {
initState = JSON.parse(storedState) as T;
}
setStored(initState);
// debug
console.debug("hooks", "useSessionStorageState()", "get", key, initState);
}
const [state, setState] = useState(initState);
const setStorageState = useCallback((newState: T) => {
if (equal(newState, state)) {
return;
}
if (newState === undefined) {
sessionStorage.removeItem(key);
} else {
sessionStorage.setItem(key, JSON.stringify(newState));
}
setState(newState);
setStored(newState);
// debug
console.debug("hooks", "useSessionStorageState()", "set", key, newState);
}, [state]);
return [state, setStorageState];
}

50
web/src/icon-active.svg Normal file
View File

@@ -0,0 +1,50 @@
<!--
Copyright 2020 kanreisa
CC BY-SA 4.0
This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.
https://creativecommons.org/licenses/by-sa/4.0/
-->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
<defs>
<style>
.a {
fill: none;
}
.b {
fill: #ffe18a;
}
.c {
fill: #fff;
}
.d {
clip-path: url(#a);
}
.e {
fill: #ffd56c;
}
.f {
fill: #ce3851;
}
</style>
<clipPath id="a">
<path class="a" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
</clipPath>
</defs>
<g>
<path class="b" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
<path class="c" d="M7.88012,12.67977C6.85,10.60206,6.679,10.467,4.362,10.12493a.13366.13366,0,0,1-.07195-.22734C5.94775,8.27582,6.02335,8.07144,5.6327,5.76218a.13366.13366,0,0,1,.194-.13867c2.05463,1.07538,2.27239,1.08414,4.34782-.00094a.133.133,0,0,1,.19226.13962c-.38821,2.28825-.32969,2.49768,1.34418,4.13676a.13367.13367,0,0,1-.07542.22621c-2.29436.33768-2.47564.45859-3.51695,2.55643A.13366.13366,0,0,1,7.88012,12.67977Z"/>
<g class="d">
<circle class="e" cx="5.13755" cy="4.86342" r="0.79995"/>
<circle class="e" cx="10.86245" cy="4.86342" r="0.8"/>
<circle class="e" cx="8" cy="13.69361" r="0.8"/>
<circle class="e" cx="3.34339" cy="10.31627" r="0.8"/>
<circle class="e" cx="12.65661" cy="10.31627" r="0.8"/>
</g>
</g>
<circle class="f" cx="13.87538" cy="2.54867" r="1.81129"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

45
web/src/icon-gray.svg Normal file
View File

@@ -0,0 +1,45 @@
<!--
Copyright 2020 kanreisa
CC BY-SA 4.0
This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.
https://creativecommons.org/licenses/by-sa/4.0/
-->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
<defs>
<style>
.a {
fill: none;
}
.b {
fill: #e0e0e0;
}
.c {
fill: #fff;
}
.d {
clip-path: url(#a);
}
.e {
fill: #d6d6d6;
}
</style>
<clipPath id="a">
<path class="a" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
</clipPath>
</defs>
<g>
<path class="b" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
<path class="c" d="M7.88012,12.67977C6.85,10.60206,6.679,10.467,4.362,10.12493a.13366.13366,0,0,1-.07195-.22734C5.94775,8.27582,6.02335,8.07144,5.6327,5.76218a.13366.13366,0,0,1,.194-.13867c2.05463,1.07538,2.27239,1.08414,4.34782-.00094a.133.133,0,0,1,.19226.13962c-.38821,2.28825-.32969,2.49768,1.34418,4.13676a.13367.13367,0,0,1-.07542.22621c-2.29436.33768-2.47564.45859-3.51695,2.55643A.13366.13366,0,0,1,7.88012,12.67977Z"/>
<g class="d">
<circle class="e" cx="5.13755" cy="4.86342" r="0.79995"/>
<circle class="e" cx="10.86245" cy="4.86342" r="0.8"/>
<circle class="e" cx="8" cy="13.69361" r="0.8"/>
<circle class="e" cx="3.34339" cy="10.31627" r="0.8"/>
<circle class="e" cx="12.65661" cy="10.31627" r="0.8"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

45
web/src/icon.svg Normal file
View File

@@ -0,0 +1,45 @@
<!--
Copyright 2020 kanreisa
CC BY-SA 4.0
This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.
https://creativecommons.org/licenses/by-sa/4.0/
-->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
<defs>
<style>
.a {
fill: none;
}
.b {
fill: #ffe18a;
}
.c {
fill: #fff;
}
.d {
clip-path: url(#a);
}
.e {
fill: #ffd56c;
}
</style>
<clipPath id="a">
<path class="a" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
</clipPath>
</defs>
<g>
<path class="b" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
<path class="c" d="M7.88012,12.67977C6.85,10.60206,6.679,10.467,4.362,10.12493a.13366.13366,0,0,1-.07195-.22734C5.94775,8.27582,6.02335,8.07144,5.6327,5.76218a.13366.13366,0,0,1,.194-.13867c2.05463,1.07538,2.27239,1.08414,4.34782-.00094a.133.133,0,0,1,.19226.13962c-.38821,2.28825-.32969,2.49768,1.34418,4.13676a.13367.13367,0,0,1-.07542.22621c-2.29436.33768-2.47564.45859-3.51695,2.55643A.13366.13366,0,0,1,7.88012,12.67977Z"/>
<g class="d">
<circle class="e" cx="5.13755" cy="4.86342" r="0.79995"/>
<circle class="e" cx="10.86245" cy="4.86342" r="0.8"/>
<circle class="e" cx="8" cy="13.69361" r="0.8"/>
<circle class="e" cx="3.34339" cy="10.31627" r="0.8"/>
<circle class="e" cx="12.65661" cy="10.31627" r="0.8"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

15
web/src/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0">
<title>...</title>
<link id="icon" rel="icon" type="image/svg+xml" href="/icon.svg">
<script defer src="/vendors.bundle.js"></script>
<script defer src="/index.bundle.js"></script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

217
web/src/index.sass Normal file
View File

@@ -0,0 +1,217 @@
@use "~@blueprintjs/colors/lib/scss/colors"
@use "./vars"
.bp5-spinner
animation: 0.2s ease 0.3s 1 normal forwards running fade-in
.bp5-navbar
display: flex
.bp5-navbar-group
&.bp5-align-left
flex: 1 1 0
&,
.bp5-navbar-heading
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
.bp5-non-ideal-state
position: absolute
z-index: 2
top: 0
left: 0
right: 0
bottom: 0
width: auto
height: auto
overflow: auto
background: colors.$light-gray4
.bp5-dark &
background: colors.$dark-gray4
body
font-family: vars.$font-base
overflow: hidden
&.indiana-dragging
cursor: default
.bp5-button > .bp5-button-text,
.bp5-menu-item
font-family: vars.$font-ui
select,
button,
.bp5-tabs
user-select: none
button:not(.bp5-button)
border: none
background: inherit
&:not(:disabled)
cursor: pointer
a[target="_blank"]::after
font-family: "blueprint-icons-16"
content: ""
margin-left: 4px
font-size: 10px
.font-bolder
font-weight: bolder !important
.color-warning
color: colors.$orange3 !important
.color-danger
color: colors.$red3 !important
.color-dow-6
color: colors.$blue3 !important
.color-dow-7
color: colors.$red3 !important
.bg-genre-lv1-0
background: rgb(255,255,224) !important
.bg-genre-lv1-1
background: rgb(224,224,255) !important
.bg-genre-lv1-2
background: rgb(255,224,240) !important
.bg-genre-lv1-3
background: rgb(255,224,224) !important
.bg-genre-lv1-4
background: rgb(224,255,224) !important
.bg-genre-lv1-5
background: rgb(224,255,255) !important
.bg-genre-lv1-6
background: rgb(255,240,224) !important
.bg-genre-lv1-7
background: rgb(255,224,255) !important
.bg-genre-lv1-8
background: rgb(255,255,224) !important
.bg-genre-lv1-9
background: rgb(255,240,224) !important
.bg-genre-lv1-10
background: rgb(224,240,255) !important
.bg-genre-lv1-11
background: rgb(224,240,255) !important
.bg-genre-lv1-15
background: rgb(240,240,240) !important
.bg-attribute-新
background: colors.$forest3 !important
.bg-attribute-再
background: colors.$cerulean3 !important
.bg-attribute-終
background: colors.$vermilion3 !important
.bg-attribute-生
background: colors.$rose4 !important
.bg-attribute-多
background: colors.$rose3 !important
.bg-attribute-解
background: colors.$sepia4 !important
.bg-attribute-初
background: colors.$lime3 !important
.bg-attribute-手
background: colors.$sepia3 !important
.bg-attribute-字
background: colors.$gray3 !important
.bg-attribute-デ,
.bg-attribute-双
background: colors.$violet3 !important
.bg-attribute-二
background: colors.$gold3 !important
.bg-attribute-無
background: colors.$turquoise3 !important
#root
display: flex
flex-direction: column
width: 100vw
height: 100vh
overflow: hidden
#dev-header
position: absolute
top: 0
left: 5px
font-size: 10px
z-index: 9999
opacity: 0.5
#main
flex-direction: column
flex-grow: 1
// overflow-y: auto
position: relative
#page
position: absolute
top: 0
right: 0
bottom: 0
left: 0
background: colors.$light-gray4
.bp5-dark &
background: colors.$dark-gray4
> .route
position: absolute
top: 0
right: 0
bottom: 0
left: 0
display: flex
flex-direction: column
flex-grow: 1
> .toolbar.bp5-navbar
z-index: 9
background: none
box-shadow: none
.bp5-navbar-heading
font-weight: 400
font-size: 18px
font-feature-settings: "palt" 1
.component-program-title
.name
font-weight: inherit
.attribute
font-size: 12px
vertical-align: 14%
.bp5-breadcrumb
font-weight: inherit
font-size: inherit
> .bp5-navbar-group
&.bp5-align-right
column-gap: 10px
.bp5-tab-list
column-gap: 20px
> .bp5-tab
&[aria-selected="true"]
cursor: default
sup[class*="color-dow-"]
font-weight: 600
margin-left: 2px
> .content
flex-grow: 1
overflow: auto
position: relative
margin: 0
padding: 10px 25px
&.no-margin
padding: 0

101
web/src/index.tsx Normal file
View File

@@ -0,0 +1,101 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Routes, Route, NavLink, useNavigate, useLocation } from "react-router-dom";
import { FocusStyleManager } from "@blueprintjs/core";
FocusStyleManager.onlyShowFocusOnTabs();
import { DateTime, Settings as LuxonSettings } from "luxon";
LuxonSettings.defaultZone = "Asia/Tokyo";
LuxonSettings.defaultLocale = "ja";
import { state } from "./modules/state";
import * as ui from "./modules/ui";
import * as at from "./modules/at";
at.init(5000);
import { Nav } from "./components/Nav";
import { EPGView } from "./routes/EPGView";
import { ProgramView } from "./routes/ProgramView";
import { SearchView } from "./routes/SearchView";
import { JobsView } from "./routes/JobsView";
import { LogsView } from "./routes/LogsView";
import { ServerConfigView } from "./routes/ServerConfigView";
import { TunersConfigView } from "./routes/TunersConfigView";
import { ChannelsConfigView } from "./routes/ChannelsConfigView";
import { HomeView } from "./routes/HomeView";
import { AboutView } from "./routes/AboutView";
import "normalize.css";
import "@blueprintjs/core/lib/css/blueprint.css";
import "@blueprintjs/icons/lib/css/blueprint-icons.css";
import "./index.sass";
const Index: React.FC = () => {
console.debug("Index");
const navigate = state.navigate = useNavigate();
const location = state.location = useLocation();
const searchParams = state.searchParams = new URLSearchParams(location.search);
const pathname = state.pathname = location.pathname;
const pathLv1 = pathname.split("/")[1];
useEffect(() => {
ui.blur();
}, [location.pathname]);
return <>
{state.isDev && /* 開発用 */ <>
<div id="dev-header">
[dev] {JSON.stringify({
pathname,
pathLv1,
searchParams: searchParams.toString()
})}
</div>
</>}
<Nav pathLv1={pathLv1} />
<div id="main">
<div id="page">
<Routes>
<Route path="/*" element={<div>not found</div>} />
<Route path="/" element={<HomeView />} />
<Route path="epg" element={<EPGView />} />
<Route path="epg/services/:globalServiceId" element={<EPGView />} />
<Route path="epg/programs/:programId" element={<ProgramView />} />
<Route path="epg/search" element={<SearchView />} />
<Route path="jobs" element={<JobsView />} />
<Route path="logs" element={<LogsView />} />
<Route path="config/server" element={<ServerConfigView />} />
<Route path="config/tuners" element={<TunersConfigView />} />
<Route path="config/channels" element={<ChannelsConfigView />} />
<Route path="about" element={<AboutView />} />
</Routes>
</div>
</div>
</>;
};
{
const basename = state.isDev ? "/dev/" : "";
const root = createRoot(document.getElementById("root"));
root.render(<BrowserRouter basename={basename}><Index /></BrowserRouter>);
}

63
web/src/modules/at.ts Normal file
View File

@@ -0,0 +1,63 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export type ScheduleId = string;
export type ScheduleTask = () => void;
const map: Record<ScheduleId, [number, ScheduleTask]> = {};
let count = 0;
let intervalId: ReturnType<typeof setInterval>;
export function setSchedule(time: number, task: ScheduleTask): ScheduleId {
const id = (++count).toString(10);
map[id] = [time, task];
console.debug("at", "setSchedule()", id, map[id]);
return id;
}
export function clearSchedule(id: ScheduleId): void {
console.debug("at", "clearSchedule()", id, map[id]);
delete map[id];
}
export function init(interval = 1000) {
if (intervalId) {
clearInterval(intervalId);
}
intervalId = setInterval(() => run(), interval);
}
export function deinit() {
if (intervalId) {
clearInterval(intervalId);
}
}
function run() {
const now = Date.now();
for (const id in map) {
const [time, task] = map[id];
if (time <= now) {
console.debug("at", "run()", id, map[id]);
delete map[id];
task();
}
}
}

119
web/src/modules/common.ts Normal file
View File

@@ -0,0 +1,119 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as regexp from "./regexp";
export async function sleep(ms: number): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
export function inRange<T = number>(value: T, min: T, max: T): boolean {
return value >= min && value <= max;
}
export function getGlobalServiceId(networkId: number, serviceId: number): number {
return parseInt(networkId + (serviceId / 100000).toFixed(5).slice(2), 10);
}
export function getIdWithHex(id: number): string {
return `0x${id.toString(16).toUpperCase()} (${id})`;
}
export function textMatch(text: string, queryNormalized: string): boolean {
if (normalizeText(text).includes(queryNormalized)) {
return true;
}
return false;
}
export function normalizeText(text: string): string {
return katakanaToHiragana(squaredUnicodeToBrackets(text).normalize("NFKC")).toLowerCase();
}
export function squaredUnicodeToBrackets(text: string): string {
return text.replace(regexp.squaredUnicode, (s) => {
return `[${s.normalize("NFKC")}]`;
});
}
export function katakanaToHiragana(text: string): string {
return text.replace(regexp.katakana, (s) => {
const code = s.charCodeAt(0) - 0x60;
return String.fromCharCode(code);
});
}
export class LazyCaller<T extends Function> {
caller: T;
private _delayTimeout: NodeJS.Timeout;
private _activate: null | any[] = null; // args
private _running = false;
/**
* 遅延時間が経過するまでコールされなかった時に指定関数を実行
* @param msDelay 最低遅延時間
* @param msSleep 実行後待機時間
* @param fn 実行関数 (Promise の場合は重複を避けて遅延実行する)
*/
constructor(public msDelay: number, public msSleep: number, public fn: T) {
// eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment
const _lazy = this;
this.caller = function lazyCaller(this: never, ...args: any[]) {
if (_lazy._running) {
_lazy._activate = args || [];
return;
}
clearTimeout(_lazy._delayTimeout);
_lazy._delayTimeout = setTimeout(async () => {
_lazy._activate = null;
_lazy._running = true;
if (_lazy.fn) {
await Reflect.apply(_lazy.fn, this, args);
}
await sleep(_lazy.msSleep);
_lazy._running = false;
if (_lazy._activate && _lazy.caller) {
setTimeout(_lazy.caller.apply(this, _lazy._activate), 0);
}
}, _lazy.msDelay);
} as any as T;
}
destroy() {
clearTimeout(this._delayTimeout);
this._activate = null;
delete this.fn;
delete this.caller;
}
}
export function copyToClipboard(text: string) {
// secure context ではないため execCommand を使用する
const input = document.createElement("input");
input.setAttribute("readonly", "readonly");
input.setAttribute("value", text);
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
}

View File

@@ -0,0 +1,292 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { IconName } from "@blueprintjs/core";
export const channelTypeMap = {
GR: "地上",
BS: "BS",
CS: "CS",
SKY: "SKY",
};
export const relatedItemTypeMap = {
shared: "イベント共有",
relay: "イベントリレー",
movement: "イベント移動"
};
export const relatedItemTypeIconMap: Record<string, IconName> = {
shared: "duplicate",
relay: "one-to-one",
movement: "flow-linear"
};
export const langMap = {
jpn: "日本語",
eng: "英語",
deu: "ドイツ語",
fra: "フランス語",
ita: "イタリア語",
rus: "ロシア語",
zho: "中国語",
kor: "韓国語",
spa: "スペイン語",
etc: "その他",
};
export const audioModeMap = {
"00001": "モノラル",
"00010": "デュアルモノ",
"00011": "ステレオ",
"00100": "2/1モード",
"00101": "3/0モード",
"00110": "2/2モード",
"00111": "3/1モード",
"01000": "3/2モード",
"01001": "5.1ch",
"01010": "3/3.1モード",
"01011": "2/0/0-2/0/2-0.1モード",
"01100": "5/2.1モード",
"01101": "3/2/2.1モード",
"01110": "2/0/0-3/0/2-0.1モード",
"01111": "0/2/0-3/0/2-0.1モード",
"10000": "2/0/0-3/2/3-0.2モード",
"10001": "3/3/3-5/2/3-3/0/0.2モード",
};
export const ProgramAttributeMap = {
: "字幕放送", // 字幕放送
: "新番組", // 新番組
: "初回放送", // 初回放送
: "最終回", // 最終回
: "再放送", // 再放送
: "番組連動データ放送", // 番組連動データ放送
: "双方向放送", // 双方向放送
: "無料放送", // 無料放送
: "二ヶ国語放送", // 二ヶ国語放送
: "音声多重放送", // 音声多重放送
SS: "サラウンドステレオ", // サラウンドステレオ
: "生放送", // 生放送
: "前編", // 前編
: "後編", // 後編
: "音声解説", // 音声解説
PPV: "PPV", // PPV
: "手話通訳放送", // 手話通訳放送
};
export const Genre1Map = {
0x0: "ニュース/報道",
0x1: "スポーツ",
0x2: "情報/ワイドショー",
0x3: "ドラマ",
0x4: "音楽",
0x5: "バラエティ",
0x6: "映画",
0x7: "アニメ/特撮",
0x8: "ドキュメンタリー/教養",
0x9: "劇場/公演",
0xA: "趣味/教育",
0xB: "福祉",
0xC: "予備",
0xD: "予備",
0xE: "拡張",
0xF: "その他",
};
export const Genre2Map = {
0x00: "定時・総合",
0x01: "天気",
0x02: "特集・ドキュメント",
0x03: "政治・国会",
0x04: "経済・市況",
0x05: "海外・国際",
0x06: "解説",
0x07: "討論・会談",
0x08: "報道特番",
0x09: "ローカル・地域",
0x0A: "交通",
0x0F: "その他",
0x10: "スポーツニュース",
0x11: "野球",
0x12: "サッカー",
0x13: "ゴルフ",
0x14: "その他の球技",
0x15: "相撲・格闘技",
0x16: "オリンピック・国際大会",
0x17: "マラソン・陸上・水泳",
0x18: "モータースポーツ",
0x19: "マリン・ウィンタースポーツ",
0x1A: "競馬・公営競技",
0x1F: "その他",
0x20: "芸能・ワイドショー",
0x21: "ファッション",
0x22: "暮らし・住まい",
0x23: "健康・医療",
0x24: "ショッピング・通販",
0x25: "グルメ・料理",
0x26: "イベント",
0x27: "番組紹介・お知らせ",
0x2F: "その他",
0x30: "国内ドラマ",
0x31: "海外ドラマ",
0x32: "時代劇",
0x3F: "その他",
0x40: "国内ロック・ポップス",
0x41: "海外ロック・ポップス",
0x42: "クラシック・オペラ",
0x43: "ジャズ・フュージョン",
0x44: "歌謡曲・演歌",
0x45: "ライブ・コンサート",
0x46: "ランキング・リクエスト",
0x47: "カラオケ・のど自慢",
0x48: "民謡・邦楽",
0x49: "童謡・キッズ",
0x4A: "民族音楽・ワールドミュージック",
0x4F: "その他",
0x50: "クイズ",
0x51: "ゲーム",
0x52: "トークバラエティ",
0x53: "お笑い・コメディ",
0x54: "音楽バラエティ",
0x55: "旅バラエティ",
0x56: "料理バラエティ",
0x5F: "その他",
0x60: "洋画",
0x61: "邦画",
0x62: "アニメ",
0x6F: "その他",
0x70: "国内アニメ",
0x71: "海外アニメ",
0x72: "特撮",
0x7F: "その他",
0x80: "社会・時事",
0x81: "歴史・紀行",
0x82: "自然・動物・環境",
0x83: "宇宙・科学・医学",
0x84: "カルチャー・伝統文化",
0x85: "文学・文芸",
0x86: "スポーツ",
0x87: "ドキュメンタリー全般",
0x88: "インタビュー・討論",
0x8F: "その他",
0x90: "現代劇・新劇",
0x91: "ミュージカル",
0x92: "ダンス・バレエ",
0x93: "落語・演芸",
0x94: "歌舞伎・古典",
0x9F: "その他",
0xA0: "旅・釣り・アウトドア",
0xA1: "園芸・ペット・手芸",
0xA2: "音楽・美術・工芸",
0xA3: "囲碁・将棋",
0xA4: "麻雀・パチンコ",
0xA5: "車・オートバイ",
0xA6: "コンピュータ・TVゲーム",
0xA7: "会話・語学",
0xA8: "幼児・小学生",
0xA9: "中学生・高校生",
0xAA: "大学生・受験",
0xAB: "生涯教育・資格",
0xAC: "教育問題",
0xAF: "その他",
0xB0: "高齢者",
0xB1: "障害者",
0xB2: "社会福祉",
0xB3: "ボランティア",
0xB4: "手話",
0xB5: "文字(字幕)",
0xB6: "音声解説",
0xBF: "その他",
0xC0: "予備",
0xD0: "予備",
0xE0: "BS/地上デジタル放送用番組付属情報",
0xE1: "広帯域CSデジタル放送用拡張",
0xE2: "衛星デジタル音声放送用拡張",
0xE3: "サーバー型番組付属情報",
0xE4: "IP放送用番組付属情報",
0xF0: "その他",
0xFF: "その他",
};
export const GenreUN1Map = {
0xE10: "スポーツ",
0xE11: "洋画",
0xE12: "邦画",
};
export const GenreUN2Map = {
0xE000: "中止の可能性あり",
0xE001: "延長の可能性あり",
0xE002: "中断の可能性あり",
0xE003: "同一シリーズの別話数放送の可能性あり",
0xE004: "編成未定枠",
0xE005: "繰り上げの可能性あり",
0xE010: "中断ニュースあり",
0xE011: "当該イベントに関連する臨時サービスあり",
0xE020: "当該イベント中に3D映像あり",
0xE100: "テニス",
0xE101: "バスケットボール",
0xE102: "ラグビー",
0xE103: "アメリカンフットボール",
0xE104: "ボクシング",
0xE105: "プロレス",
0xE10F: "その他",
0xE110: "アクション",
0xE111: "SF/ファンタジー",
0xE112: "コメディー",
0xE113: "サスペンス/ミステリー",
0xE114: "恋愛/ロマンス",
0xE115: "ホラー/スリラー",
0xE116: "ウエスタン",
0xE117: "ドラマ/社会派ドラマ",
0xE118: "アニメーション",
0xE119: "ドキュメンタリー",
0xE11A: "アドベンチャー/冒険",
0xE11B: "ミュージカル/音楽映画",
0xE11C: "ホームドラマ",
0xE11F: "その他",
0xE120: "アクション",
0xE121: "SF/ファンタジー",
0xE122: "お笑い/コメディー",
0xE123: "サスペンス/ミステリー",
0xE124: "恋愛/ロマンス",
0xE125: "ホラー/スリラー",
0xE126: "青春/学園/アイドル",
0xE127: "任侠/時代劇",
0xE128: "アニメーション",
0xE129: "ドキュメンタリー",
0xE12A: "アドベンチャー/冒険",
0xE12B: "ミュージカル/音楽映画",
0xE12C: "ホームドラマ",
0xE12F: "その他",
};

35
web/src/modules/regexp.ts Normal file
View File

@@ -0,0 +1,35 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/** カタカナ */
export const katakana = /[\u30A1-\u30F6]/ug;
/** Unicode 囲み文字 (四角形) */
export const squaredUnicode = /[\u{1F130}-\u{1F14E}\u{1F201}-\u{1F23B}]/ug;
/** Unicode 囲み文字 (属性用) */
export const enclosedAttributeUnicode = /[\u{1F14D}-\u{1F14E}\u{1F210}-\u{1F222}]/ug;
/** レガシー属性フォーマット */
export const legacyAttributeFormat = /(?:[\[][新生無][\]]|\([二字]\)|[\[【]無料[\]】])/g;
/** EPG オートリンク用 */
export const epgHTTPLinkFormat = /(?:(https?:\/\/[\x21-\x7e]+)|(?[\uFF01-\uFF5E]+)|(www\.[\x21-\x7e]+))/gi;
/** EPG オートリンク用 */
export const epgXLinkFormat = /(?:Twitter||X|)[\s\S]{0,14}([@][\da-z_]+)/gi
/** EPG オートリンク用 */
export const epgInstagramLinkFormat = /(?:Instagram||インスタグラム)[\s\S]{0,14}([@][\da-z_]+)/gi

427
web/src/modules/state.ts Normal file
View File

@@ -0,0 +1,427 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventEmitter } from "eventemitter3";
import { DateTime } from "luxon";
import { useNavigate, useLocation } from "react-router-dom";
import { Client as RPCClient } from "jsonrpc2-ws";
import { setSchedule } from "./at";
import * as ui from "./ui";
import { Event, Service, Program, TunerDevice, Status, JobItem, JobScheduleItem, ConfigServer } from "../../../api.d";
import { JoinParams, NotifyParams } from "../../types/rpc";
type StatusIconKey = "normal" | "offline" | "active";
type StateEventTypes = {
"todayTime": [number];
"statusName": [string];
"statusIconKey": [StatusIconKey];
"version": [string];
"status": [Status];
"services": [Service[]];
"tuners": [TunerDevice[]];
"jobs": [JobItem[]];
"jobSchedules": [JobScheduleItem[]];
"programs": [Program[]];
"logs": [string[], boolean];
};
import normalIcon from "../icon.svg";
import offlineIcon from "../icon-gray.svg";
import activeIcon from "../icon-active.svg";
const iconSrcMap = {
normal: normalIcon,
offline: offlineIcon,
active: activeIcon
};
const jobStatusOrderMap = {
queued: 0,
standby: 1,
running: 2,
finished: 3
};
class State extends EventEmitter<StateEventTypes> {
isDev: boolean = /^\/dev\/.*$/.test(location.pathname);
navigate?: ReturnType<typeof useNavigate>;
location?: ReturnType<typeof useLocation>;
pathname?: string;
searchParams?: URLSearchParams;
todayTime?: number;
version = "..";
statusName = "Loading";
statusIconKey: StatusIconKey = "offline";
status?: Status;
services: Service[] = [];
tuners: TunerDevice[] = [];
jobs: JobItem[] = [];
jobSchedules: JobScheduleItem[] = [];
programs: Program[] = [];
serverConfig?: ConfigServer;
private _rpc?: RPCClient;
constructor() {
super();
if (!this.isDev) {
const emptyFunction = function () {};
if (console?.debug) {
console.debug = emptyFunction;
}
if (console?.log) {
console.log = emptyFunction;
}
}
this._setTodayTime();
this._initRPC();
this.on("tuners", () => this._updateIdleStatus());
this.on("statusIconKey", key => ui.setFavicon(iconSrcMap[key]));
}
get statusIconSrc() {
return iconSrcMap[this.statusIconKey];
}
async fetchStatus(): Promise<Status> {
this.status = await this._rpc.call("getStatus");
this.emit("status", this.status);
// version
if (this.version !== ".." && this.version !== this.status.version) {
location.reload();
return;
}
this.version = this.status.version;
this.emit("version", this.version);
return this.status;
}
async fetchServices(): Promise<Service[]> {
this.services = await this._rpc.call("getServices");
if (this.services.length > 0) {
this.emit("services", this.services);
}
return this.services;
}
async fetchTuners(): Promise<TunerDevice[]> {
this.tuners.splice(0, this.tuners.length, ...await this._rpc.call("getTuners"));
if (this.tuners.length > 0) {
this.emit("tuners", this.tuners);
}
return this.tuners;
}
async fetchJobs(): Promise<JobItem[]> {
this.jobs.splice(0, this.jobs.length, ...await this._rpc.call("getJobs"));
if (this.jobs.length > 0) {
this._handleJobs();
this.emit("jobs", this.jobs);
}
return this.jobs;
}
async fetchJobSchedules(): Promise<JobScheduleItem[]> {
this.jobSchedules = await this._rpc.call("getJobSchedules");
if (this.jobSchedules.length > 0) {
this.emit("jobSchedules", this.jobSchedules);
}
return this.jobSchedules;
}
async fetchPrograms(): Promise<Program[]> {
this.programs = await (await fetch("/api/programs")).json();
if (this.programs.length > 0) {
this.emit("programs", this.programs);
}
return this.programs;
}
private _joinProgramEvents: () => void;
async subscribePrograms(forceEmit = false): Promise<void> {
if (this._joinProgramEvents) {
if (forceEmit) {
this.emit("programs", this.programs);
}
return;
}
this._joinProgramEvents = () => {
this.fetchPrograms();
this._rpc.call("join", {
rooms: ["events:program"],
} as JoinParams);
};
this._rpc.on("connected", this._joinProgramEvents);
if (this._rpc.isConnected()) {
this._joinProgramEvents();
}
}
async unsubscribePrograms(): Promise<void> {
if (this._rpc.isConnected()) {
this._rpc.call("leave", {
rooms: ["events:program"],
} as JoinParams);
}
if (this._joinProgramEvents) {
this._rpc.off("connected", this._joinProgramEvents);
this._joinProgramEvents = undefined;
}
}
async fetchServerConfig(): Promise<ConfigServer> {
this.serverConfig = await (await fetch("/api/config/server")).json();
return this.serverConfig;
}
private _setTodayTime() {
console.debug("state", "setTodayTime()");
const init = !this.todayTime;
this.todayTime = DateTime.now().startOf("day").toMillis();
if (!init) {
this.emit("todayTime", this.todayTime);
}
// update daily
setSchedule(this.todayTime + 86400000, () => this._setTodayTime());
}
private _initRPC() {
const rpc = this._rpc = new RPCClient(`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/rpc`, {
protocols: null,
bufferSendingMessages: false
});
rpc.on("connecting", () => {
console.debug("rpc:connecting");
this.statusName = "Connecting";
this.statusIconKey = "offline";
this.emit("statusName", this.statusName);
this.emit("statusIconKey", this.statusIconKey);
});
let _statusRefreshInterval: NodeJS.Timeout | undefined;
let _servicesRefreshInterval: NodeJS.Timeout | undefined;
rpc.on("connected", async () => {
console.debug("rpc:connected");
this.programs = [];
this.statusName = "Connected";
this.statusIconKey = "normal";
this.emit("statusName", this.statusName);
this.emit("statusIconKey", this.statusIconKey);
await rpc.call("join", {
rooms: [
"events:service",
"events:tuner",
"events:job",
"events:job_schedule"
],
} as JoinParams);
await this.fetchStatus();
await this.fetchServices();
await this.fetchTuners();
await this.fetchJobs();
await this.fetchJobSchedules();
// periodic refresh (every 5s)
_statusRefreshInterval = setInterval(async () => {
if (document.hidden) { return; }
await this.fetchStatus();
}, 1000 * 5);
// periodic refresh (every 60s, for data not covered by push events like logo)
_servicesRefreshInterval = setInterval(async () => {
if (document.hidden) { return; }
await this.fetchServices();
}, 1000 * 60);
});
rpc.on("disconnect", () => {
console.debug("rpc:disconnected");
if (_statusRefreshInterval) {
clearInterval(_statusRefreshInterval);
_statusRefreshInterval = undefined;
}
if (_servicesRefreshInterval) {
clearInterval(_servicesRefreshInterval);
_servicesRefreshInterval = undefined;
}
this.statusName = "Disconnected";
this.statusIconKey = "offline";
this.emit("statusName", this.statusName);
this.emit("statusIconKey", this.statusIconKey);
});
rpc.methods.set("events", async (socket, { array }: NotifyParams<Event>) => {
let programsUpdated = false;
let servicesUpdated = false;
let tunersUpdated = false;
let jobsUpdated = false;
let jobSchedulesUpdated = false;
for (const event of array) {
switch (event.resource) {
case "program": {
const program = event.data as Program;
const index = this.programs.findIndex(p => p.id === program.id);
if (event.type === "remove") {
if (index !== -1) {
this.programs.splice(index, 1);
}
} else {
if (index === -1) {
this.programs.push(program);
} else {
this.programs.splice(index, 1, program);
}
}
programsUpdated = true;
break;
}
case "service": {
const service = event.data as Service;
const index = this.services.findIndex(s => s.id === service.id);
if (index === -1) {
this.services.push(service);
} else {
this.services[index] = {
...this.services[index],
...service
};
}
servicesUpdated = true;
break;
}
case "tuner": {
const tuner = event.data as TunerDevice;
this.tuners[this.tuners.findIndex(value => value.index === tuner.index)] = tuner;
tunersUpdated = true;
break;
}
case "job": {
const job = event.data as JobItem;
const index = this.jobs.findIndex(j => j.id === job.id);
if (index === -1) {
this.jobs.unshift(job);
} else {
this.jobs.splice(index, 1, job);
}
jobsUpdated = true;
break;
}
case "job_schedule": {
const jobSchedule = event.data as JobScheduleItem;
const index = this.jobSchedules.findIndex(j => j.key === jobSchedule.key);
if (index === -1) {
this.jobSchedules.push(jobSchedule);
} else {
this.jobSchedules.splice(index, 1, jobSchedule);
}
jobSchedulesUpdated = true;
break;
}
}
}
if (programsUpdated) {
this.emit("programs", this.programs);
}
if (servicesUpdated) {
this.emit("services", this.services);
}
if (tunersUpdated) {
this.emit("tuners", this.tuners);
}
if (jobsUpdated) {
this._handleJobs();
this.emit("jobs", this.jobs);
}
if (jobSchedulesUpdated) {
this.emit("jobSchedules", this.jobSchedules);
}
});
// ログイベントの処理
rpc.methods.set("logs", async (socket, { array }: NotifyParams<string>) => {
// 配列から文字列を抽出し、unshift=false末尾に追加でイベントを発行
this.emit("logs", array, false);
});
}
private _handleJobs() {
this.jobs.sort((a, b) => {
if (a.status === b.status) {
if (a.finishedAt && b.finishedAt) {
return b.finishedAt - a.finishedAt;
}
if (a.startedAt && b.startedAt) {
return b.startedAt - a.startedAt;
}
if (a.createdAt && b.createdAt) {
return b.createdAt - a.createdAt;
}
return b.id.localeCompare(a.id);
}
return jobStatusOrderMap[a.status] - jobStatusOrderMap[b.status];
});
// drop old jobs
if (this.jobs.length > 200) {
this.jobs.splice(200, this.jobs.length - 200);
}
}
private _updateIdleStatus() {
let statusName = "Standby";
let statusIconKey: StatusIconKey = "normal";
const isActive = this.tuners.some(tuner => tuner.isUsing === true && tuner.users.some(user => user.priority !== -1));
if (isActive) {
statusName = "Active";
statusIconKey = "active";
}
if (this.statusName !== statusName) {
this.statusName = statusName;
this.statusIconKey = statusIconKey;
this.emit("statusName", this.statusName);
this.emit("statusIconKey", this.statusIconKey);
}
}
}
export const state = new State();

80
web/src/modules/ui.ts Normal file
View File

@@ -0,0 +1,80 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { LazyCaller } from "./common";
import * as regexp from "./regexp";
export function blur(): void {
(document.activeElement as HTMLElement)?.blur();
}
export const setTitle = (() => {
const lazy = new LazyCaller(100, 0, _setTitle);
return lazy.caller.bind(this) as typeof _setTitle;
})();
export function _setTitle(title: string, loading?: boolean): void {
if (!title) {
return;
}
title = title.replace(regexp.squaredUnicode, "").replace(regexp.legacyAttributeFormat, "");
if (!title) {
return;
}
if (!loading) {
const elements = document.querySelectorAll(".heading-title");
elements.forEach(element => {
element.classList.remove("bp5-skeleton");
element.textContent = title;
});
}
document.title = `${title.normalize("NFKC")} | Mirakurun`;
}
let _faviconElement: HTMLLinkElement;
export function setFavicon(src: string) {
if (!_faviconElement) {
_faviconElement = document.querySelector("link[rel*='icon']") as HTMLLinkElement;
}
if (_faviconElement) {
_faviconElement.href = src;
}
}
export function autoLink(text: string): string {
return text
.replace(regexp.epgHTTPLinkFormat, text => {
let url = text.normalize("NFKC");
if (!/^http/.test(url)) {
url = `https://${url}`;
}
return `<a referrerpolicy="no-referrer" target="_blank" href="${url}" title="外部サイト">${text}</a>`;
})
.replace(regexp.epgXLinkFormat, (text, username) => {
return text.replace(username,
`<a referrerpolicy="no-referrer" target="_blank" href="https://x.com/${username.slice(1)}" title="X">${username}</a>`
);
})
.replace(regexp.epgInstagramLinkFormat, (text, username) => {
return text.replace(username,
`<a referrerpolicy="no-referrer" target="_blank" href="https://instagram.com/${username.slice(1)}" title="Instagram">${username}</a>`
);
});
}

37
web/src/redoc-ui.html Normal file
View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0">
<title>Mirakurun API Documentation</title>
<link id="icon" rel="icon" type="image/svg+xml" href="/icon.svg">
<script src="/redoc/redoc.standalone.js#/redoc@999.9.9+dummy-for-redoc-try/"></script>
<script src="/redoc-try/try.js"></script>
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="redoc-container"></div>
<script>
initTry({
openApi: "/api/docs",
redocOptions: {
scrollYOffset: 0,
hideDownloadButton: true,
theme: {
colors: {
primary: {
main: "#1976d2"
}
}
}
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,108 @@
@use "~@blueprintjs/colors/lib/scss/colors"
#route-about-view
.about-container
max-width: 922px // opencollective img width is 890 + card padding
margin: 0 auto
display: flex
flex-direction: column
gap: 16px
padding-bottom: 24px
.about-card, .heart-card
padding: 24px
.about-header
display: flex
align-items: center
gap: 16px
margin-bottom: 16px
.product-icon
width: 48px
height: 48px
h3
margin: 0
.about-info
margin-top: 16px
margin-bottom: 16px
.info-table
width: 100%
td:first-child
width: 150px
font-weight: bold
.warranty-warning
background: colors.$light-gray5
border-left: 4px solid colors.$red3
padding: 12px 16px
margin-bottom: 16px
border-radius: 0 4px 4px 0
.warranty-text
font-weight: bold
color: colors.$red2
.links
display: flex
gap: 8px
.consent
margin-top: 16px
padding: 16px
background: colors.$light-gray5
border-radius: 4px
p
margin-bottom: 12px
.contributors-list
margin-top: 16px
display: flex
flex-direction: column
gap: 24px
.section
h5
margin-bottom: 8px
display: flex
align-items: center
gap: 8px
.text-small
font-size: 0.85em
.image-container
margin-top: 12px
overflow-x: auto
.opencollective-img
max-width: 100%
height: auto
display: block
.sponsors-avatars
margin-top: 12px
display: flex
flex-wrap: wrap
gap: 8px
.sponsor-avatar
padding: 4px
transition: transform 0.2s, box-shadow 0.2s
&:hover
transform: scale(1.05)
body.bp5-dark
#route-about-view
.warranty-warning
background: colors.$dark-gray4
.warranty-text
color: colors.$red4
.consent
background: colors.$dark-gray4

View File

@@ -0,0 +1,188 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import { Alignment, Breadcrumbs, Button, Card, Divider, Elevation, H3, H5, Navbar, Text } from "@blueprintjs/core";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import { VersionStatus } from "../components/VersionStatus";
import "./AboutView.sass";
export const AboutView: React.FC = () => {
console.debug("routes", "AboutView");
ui.setTitle("Mirakurun について");
const [version, setVersion] = useState<string>(state.version);
useEffect(() => {
const onVersion = () => {
setVersion(state.version);
};
state.on("version", onVersion);
return () => {
state.off("version", onVersion);
};
}, []);
const [consented, setConsented] = useState<boolean>(false);
const toolbar = (
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
<Breadcrumbs items={[
{
text: "Mirakurun について"
}
]} />
</Navbar.Heading>
</Navbar.Group>
</Navbar>
);
return (
<div className="route" id="route-about-view">
{toolbar}
<div className="content">
<div className="about-container">
<Card elevation={Elevation.ONE} className="about-card">
<div className="about-header">
<img className="product-icon" src={state.statusIconSrc} alt={state.statusName} />
<H3>Mirakurun</H3>
</div>
<Divider />
<div className="about-info">
<table className="bp5-html-table bp5-html-table-striped bp5-html-table-condensed info-table">
<tbody>
<tr>
<td>Current</td>
<td>{version}</td>
</tr>
<tr>
<td>Latest</td>
<td><VersionStatus /></td>
</tr>
<tr>
<td>License</td>
<td>Apache License 2.0</td>
</tr>
<tr>
<td>Copyright</td>
<td>Copyright &copy; 2016-2026 <a href="https://github.com/kanreisa" target="_blank" rel="noreferrer">kanreisa</a></td>
</tr>
</tbody>
</table>
</div>
<div className="warranty-warning">
<Text className="warranty-text">
Mirakurun comes with ABSOLUTELY NO WARRANTY. USE AT YOUR OWN RISK.
</Text>
</div>
<div className="links">
<Button
icon="git-branch"
text="GitHub Repository"
onClick={() => window.open("https://github.com/Chinachu/Mirakurun", "_blank")}
variant="minimal"
/>
<Button
icon="globe"
text="Chinachu Project"
onClick={() => window.open("https://chinachu.moe/", "_blank")}
variant="minimal"
/>
</div>
</Card>
<Card elevation={Elevation.ONE} className="heart-card">
<H5>Special Thanks</H5>
{consented === false ? (
<div className="consent">
<p>We sincerely thank you for your continued support.</p>
<p>
This page is attempting to retrieve images from your browser by going directly to{" "}
<a href="https://opencollective.com/" target="_blank" rel="noreferrer">
opencollective.com
</a>{" "}
in order to display a list of contributors.
</p>
<Button
intent="primary"
text="Continue"
onClick={() => setConsented(true)}
/>
</div>
) : (
<div className="contributors-list">
<div className="section">
<H5>Contributors</H5>
<p>This project exists thanks to all the people who contribute.</p>
<div className="image-container">
<a href="https://github.com/Chinachu/Mirakurun/graphs/contributors" target="_blank" rel="noreferrer">
<img src="https://opencollective.com/Mirakurun/contributors.svg?width=890&button=false" alt="Contributors" className="opencollective-img" />
</a>
</div>
</div>
<Divider />
<div className="section">
<H5>
Backers{" "}
<span className="bp5-text-muted text-small">
[<a href="https://opencollective.com/Mirakurun#backer" target="_blank" rel="noreferrer">Become a backer</a>]
</span>
</H5>
<p>Thank you to all our backers! 🙏</p>
<div className="image-container">
<a href="https://opencollective.com/Mirakurun#backers" target="_blank" rel="noreferrer">
<img src="https://opencollective.com/Mirakurun/backers.svg?width=890" alt="Backers" className="opencollective-img" />
</a>
</div>
</div>
<Divider />
<div className="section">
<H5>
Sponsors{" "}
<span className="bp5-text-muted text-small">
[<a href="https://opencollective.com/Mirakurun#sponsor" target="_blank" rel="noreferrer">Become a sponsor</a>]
</span>
</H5>
<p>Support this project by becoming a sponsor. Your logo will show up here with a link to your website.</p>
<div className="sponsors-avatars">
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => (
<a key={i} href={`https://opencollective.com/Mirakurun/sponsor/${i}/website`} target="_blank" rel="noreferrer">
<img src={`https://opencollective.com/Mirakurun/sponsor/${i}/avatar.svg`} alt={`Sponsor ${i}`} className="sponsor-avatar" />
</a>
))}
</div>
</div>
</div>
)}
</Card>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,78 @@
@use "~@blueprintjs/colors/lib/scss/colors"
#route-channels-config-view
.content
display: flex
flex-direction: column
gap: 16px
padding: 20px
overflow-y: auto
.channels-table
width: 100%
border-collapse: collapse
th, td
vertical-align: top !important
padding: 12px 8px !important
td
.bp5-form-group
margin-bottom: 8px
&:last-child
margin-bottom: 0
.bp5-label
margin-bottom: 3px
font-weight: 600
font-size: 11px
color: colors.$gray1
.bp5-dark &
color: colors.$gray4
.channel-options-grid
display: flex
gap: 12px
align-items: flex-start
flex-wrap: wrap
.cmd-vars-container
display: flex
flex-direction: column
gap: 4px
min-width: 200px
flex: 1
.cmd-vars-title
font-weight: 600
font-size: 11px
margin-bottom: 4px
color: colors.$gray1
.bp5-dark &
color: colors.$gray4
.cmd-vars-list
display: flex
flex-wrap: wrap
gap: 6px
align-items: center
.cmd-var-pair
display: flex
align-items: center
gap: 4px
background-color: rgba(colors.$light-gray1, 0.4)
padding: 2px 6px
border-radius: 4px
.bp5-dark &
background-color: rgba(colors.$dark-gray5, 0.4)
.cmd-var-key, .cmd-var-value
width: 75px
.controls-cell
display: flex
gap: 4px
justify-content: flex-end
align-items: center

View File

@@ -0,0 +1,915 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import {
Alignment,
Breadcrumbs,
Button,
Callout,
Dialog,
DialogBody,
DialogFooter,
FormGroup,
HTMLSelect,
HTMLTable,
InputGroup,
Navbar,
NonIdealState,
ProgressBar,
Spinner,
Switch
} from "@blueprintjs/core";
import equal from "fast-deep-equal";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import { ConfigChannels, ConfigChannelsItem, ChannelType, ChannelScanStatus } from "../../../api.d";
import "./ChannelsConfigView.sass";
const configAPI = "/api/config/channels";
const typesIndex = ["GR", "BS", "CS", "SKY"];
function sortTypes(types: ChannelType[]): ChannelType[] {
return types.sort((a, b) => typesIndex.indexOf(a) - typesIndex.indexOf(b));
}
// チャンネル範囲を展開する関数(例: "14-16,18" → "14,15,16,18"
function expandChannelRanges(input: string): string {
if (!input) return "";
const parts = input.split(",");
const result: number[] = [];
for (const part of parts) {
if (part.includes("-")) {
const [start, end] = part.split("-").map(n => parseInt(n.trim(), 10));
if (!isNaN(start) && !isNaN(end)) {
for (let i = start; i <= end; i++) {
result.push(i);
}
}
} else {
const num = parseInt(part.trim(), 10);
if (!isNaN(num)) {
result.push(num);
}
}
}
return [...new Set(result)].sort((a, b) => a - b).join(",");
}
const migrateChannels = (channels: ConfigChannels): ConfigChannels => {
return channels.map(ch => {
if ((ch.satellite || ch.space !== undefined || ch.freq !== undefined || ch.polarity) && (!ch.commandVars || Object.keys(ch.commandVars).length === 0)) {
const commandVars: Record<string, string | number> = {};
if (ch.satellite) {
commandVars["satellite"] = ch.satellite;
}
if (ch.space !== undefined) {
commandVars["space"] = ch.space;
}
if (ch.freq !== undefined) {
commandVars["freq"] = ch.freq;
}
if (ch.polarity) {
commandVars["polarity"] = ch.polarity;
}
return {
...ch,
commandVars
};
}
return ch;
});
};
export const ChannelsConfigView: React.FC = () => {
console.debug("routes", "ChannelsConfigView");
const [current, setCurrent] = useState<ConfigChannels | null>(null);
const [editing, setEditing] = useState<ConfigChannels | null>(null);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [saved, setSaved] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// チャンネルスキャンのためのステート
const [showScanDialog, setShowScanDialog] = useState(false);
const [scanType, setScanType] = useState<ChannelType>("GR");
const [scanMinCh, setScanMinCh] = useState("13");
const [scanMaxCh, setScanMaxCh] = useState("62");
const [scanSkipCh, setScanSkipCh] = useState("");
const [scanMinSubCh, setScanMinSubCh] = useState("0");
const [scanMaxSubCh, setScanMaxSubCh] = useState("3");
const [scanUseSubCh, setScanUseSubCh] = useState(true);
const [scanChannelNameFormatEnabled, setScanChannelNameFormatEnabled] = useState(false);
const [scanChannelNameFormat, setScanChannelNameFormat] = useState("");
const [scanSetDisabledOnAdd, setScanSetDisabledOnAdd] = useState(false);
const [scanAutoApply, setScanAutoApply] = useState(false);
const [scanRefresh, setScanRefresh] = useState(false);
const [scanStatus, setScanStatus] = useState<ChannelScanStatus | null>(null);
const [scanInProgress, setScanInProgress] = useState(false);
const [showScanResultDialog, setShowScanResultDialog] = useState(false);
ui.setTitle("チャンネル設定", isLoading);
// スキャンステータスを取得する
const fetchScanStatus = async () => {
try {
const res: ChannelScanStatus = await (await fetch("/api/config/channels/scan")).json();
console.log("ChannelsConfigView", "GET", "/api/config/channels/scan", "->", res);
setScanStatus(res);
setScanInProgress(prev => {
if (res.status === "completed" && prev && !res.isScanning) {
setShowScanResultDialog(true);
}
return res.isScanning;
});
} catch (e) {
console.error("Failed to fetch scan status:", e);
}
};
// スキャンを開始する
const startScan = async () => {
try {
const params = new URLSearchParams();
params.append("type", scanType);
params.append("minCh", scanMinCh);
params.append("maxCh", scanMaxCh);
if (scanSkipCh.trim()) {
const expandedSkipCh = expandChannelRanges(scanSkipCh.trim());
params.append("skipCh", expandedSkipCh);
}
if (scanType === "BS" && scanUseSubCh) {
params.append("minSubCh", scanMinSubCh);
params.append("maxSubCh", scanMaxSubCh);
params.append("useSubCh", "true");
}
if (!scanAutoApply) {
params.append("dryRun", "true");
}
if (scanChannelNameFormatEnabled && scanChannelNameFormat.trim()) {
params.append("channelNameFormat", scanChannelNameFormat.trim());
}
params.append("setDisabledOnAdd", scanSetDisabledOnAdd ? "true" : "false");
if (scanRefresh) {
params.append("refresh", "true");
}
params.append("async", "true");
const url = `/api/config/channels/scan?${params.toString()}`;
console.log("ChannelsConfigView", "PUT", url);
const response = await fetch(url, { method: "PUT" });
const result = await response.json();
if (response.status === 202) {
console.log("Scan started:", result);
setScanInProgress(true);
setShowScanDialog(false);
await fetchScanStatus();
} else {
console.error("Failed to start scan:", result);
}
} catch (e) {
console.error("Error starting scan:", e);
}
};
// スキャンを停止する
const stopScan = async () => {
try {
const response = await fetch("/api/config/channels/scan", { method: "DELETE" });
console.log("ChannelsConfigView", "DELETE", "/api/config/channels/scan", "->", await response.json());
setScanInProgress(false);
} catch (e) {
console.error("Error stopping scan:", e);
}
};
// スキャン結果を適用する
const applyScanResult = () => {
if (scanStatus && scanStatus.result) {
setEditing(JSON.parse(JSON.stringify(scanStatus.result)));
setShowScanResultDialog(false);
}
};
// 初期データ読み込み
useEffect(() => {
if (saved === true) {
setTimeout(() => {
// Restart notification will be emitted in production when requested
}, 500);
setSaved(false);
return;
}
(async () => {
try {
const res = await (await fetch(configAPI)).json();
console.log("ChannelsConfigView", "GET", configAPI, "->", res);
const migrated = migrateChannels(res);
setEditing(JSON.parse(JSON.stringify(migrated)));
setCurrent(JSON.parse(JSON.stringify(migrated)));
setIsLoading(false);
} catch (e) {
console.error(e);
setIsLoading(false);
}
})();
}, [saved]);
// スキャン状態の定期チェック
useEffect(() => {
fetchScanStatus();
}, [current]);
useEffect(() => {
let intervalId: NodeJS.Timeout;
if (scanInProgress) {
intervalId = setInterval(fetchScanStatus, 5000);
} else {
intervalId = setInterval(fetchScanStatus, 30000);
}
return () => {
if (intervalId) {
clearInterval(intervalId);
}
};
}, [scanInProgress]);
const hasChanges = editing !== null && current !== null && !equal(editing, current);
const handleCancel = () => {
if (current) {
setEditing(JSON.parse(JSON.stringify(current)));
}
};
const handleSave = async () => {
if (!editing) return;
setShowSaveDialog(false);
try {
console.log("ChannelsConfigView", "PUT", configAPI, "<-", editing);
await fetch(configAPI, {
method: "PUT",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(editing)
});
setSaved(true);
} catch (err) {
console.error(err);
}
};
const handleAddChannel = () => {
if (!editing) return;
const i = editing.length;
const newChannel: ConfigChannelsItem = {
name: `ch${i}`,
type: "GR",
channel: "0",
isDisabled: true
};
setEditing([...editing, newChannel]);
};
const updateChannel = (index: number, updated: Partial<ConfigChannelsItem>) => {
if (!editing) return;
const newEditing = [...editing];
newEditing[index] = { ...newEditing[index], ...updated };
setEditing(newEditing);
};
const deleteChannelProperty = (index: number, key: keyof ConfigChannelsItem) => {
if (!editing) return;
const newEditing = [...editing];
const updated = { ...newEditing[index] };
delete updated[key];
newEditing[index] = updated;
setEditing(newEditing);
};
const handleUp = (i: number) => {
if (!editing || i === 0) return;
const newEditing = [...editing];
const temp = newEditing[i];
newEditing[i] = newEditing[i - 1];
newEditing[i - 1] = temp;
setEditing(newEditing);
};
const handleDown = (i: number) => {
if (!editing || i === editing.length - 1) return;
const newEditing = [...editing];
const temp = newEditing[i];
newEditing[i] = newEditing[i + 1];
newEditing[i + 1] = temp;
setEditing(newEditing);
};
const handleRemove = (i: number) => {
if (!editing) return;
const newEditing = [...editing];
newEditing.splice(i, 1);
setEditing(newEditing);
};
// Command Var Helpers
const updateCommandVarKey = (chIndex: number, oldKey: string, newKey: string) => {
if (!editing) return;
const newEditing = [...editing];
const ch = { ...newEditing[chIndex] };
const commandVars = { ...(ch.commandVars || {}) };
const updatedVars: Record<string, string | number> = {};
Object.entries(commandVars).forEach(([k, v]) => {
if (k === oldKey) {
updatedVars[newKey] = v;
} else {
updatedVars[k] = v;
}
});
ch.commandVars = updatedVars;
newEditing[chIndex] = ch;
setEditing(newEditing);
};
const updateCommandVarValue = (chIndex: number, key: string, newValue: string) => {
if (!editing) return;
const newEditing = [...editing];
const ch = { ...newEditing[chIndex] };
const commandVars = { ...(ch.commandVars || {}) };
if (newValue === "") {
commandVars[key] = "";
} else if (newValue === "0") {
commandVars[key] = 0;
} else if (/^[0-9]+(\.[0-9]+)?$/.test(newValue)) {
commandVars[key] = parseFloat(newValue);
} else {
commandVars[key] = newValue;
}
ch.commandVars = commandVars;
newEditing[chIndex] = ch;
setEditing(newEditing);
};
const removeCommandVar = (chIndex: number, key: string) => {
if (!editing) return;
const newEditing = [...editing];
const ch = { ...newEditing[chIndex] };
const commandVars = { ...(ch.commandVars || {}) };
delete commandVars[key];
if (Object.keys(commandVars).length === 0) {
delete ch.commandVars;
} else {
ch.commandVars = commandVars;
}
newEditing[chIndex] = ch;
setEditing(newEditing);
};
const addCommandVar = (chIndex: number) => {
if (!editing) return;
const newEditing = [...editing];
const ch = { ...newEditing[chIndex] };
const commandVars = { ...(ch.commandVars || {}) };
let newKey = "arg";
let counter = 1;
while (commandVars[newKey] !== undefined) {
newKey = `arg${counter}`;
counter++;
}
commandVars[newKey] = "";
ch.commandVars = commandVars;
newEditing[chIndex] = ch;
setEditing(newEditing);
};
const toolbar = (
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
<Breadcrumbs items={[
{
text: "チャンネル設定"
}
]} />
</Navbar.Heading>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
<Button
minimal
intent="success"
icon="add"
text="Add Channel"
onClick={handleAddChannel}
/>
<Button
minimal
intent="warning"
icon="search"
text="Channel Scan"
onClick={() => setShowScanDialog(true)}
disabled={scanInProgress}
/>
<Navbar.Divider />
<Button
minimal
intent="danger"
icon="undo"
text="Cancel"
disabled={!hasChanges}
onClick={handleCancel}
/>
<Button
intent="primary"
icon="saved"
text="Save"
disabled={!hasChanges}
onClick={() => setShowSaveDialog(true)}
/>
</Navbar.Group>
</Navbar>
);
if (isLoading || !editing) {
return (
<div className="route" id="route-channels-config-view">
{toolbar}
<NonIdealState
icon={<Spinner />}
title="ロード中"
description="設定を読み込んでいます..."
/>
</div>
);
}
return (
<div className="route" id="route-channels-config-view">
{toolbar}
<div className="content">
{/* スキャン進行中/完了時のステータス表示 */}
{scanInProgress && scanStatus && (
<Callout intent="primary" title={`チャンネルスキャン中 (${scanStatus.type})`}>
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginTop: "8px" }}>
<ProgressBar value={(scanStatus.progress || 0) / 100} />
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
: <strong>{scanStatus.currentChannel || "初期化中..."}</strong> (: {scanStatus.progress || 0}%)
<span style={{ marginLeft: "16px" }}>: {scanStatus.newCount || 0} / : {scanStatus.takeoverCount || 0}</span>
</div>
<div style={{ display: "flex", gap: "8px" }}>
<Button small icon="refresh" onClick={fetchScanStatus}></Button>
<Button small intent="danger" icon="stop" onClick={stopScan}></Button>
</div>
</div>
</div>
</Callout>
)}
{!scanInProgress && scanStatus && (scanStatus.status === "completed" || (scanStatus.scanLog && scanStatus.scanLog.length > 0)) && (
<Callout
intent={scanStatus.status === "completed" ? "success" : "warning"}
title={`前回のスキャン結果 (${scanStatus.type})`}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: "8px" }}>
<div>
: <strong>{scanStatus.status}</strong>
<span style={{ marginLeft: "16px" }}>: {scanStatus.newCount || 0} / : {scanStatus.takeoverCount || 0}</span>
</div>
<div style={{ display: "flex", gap: "8px" }}>
{scanStatus.status === "completed" && scanStatus.result && (
<Button small intent="success" icon="tick" onClick={applyScanResult}></Button>
)}
<Button small icon="document" onClick={() => setShowScanResultDialog(true)}></Button>
</div>
</div>
</Callout>
)}
<HTMLTable className="channels-table" striped interactive>
<thead>
<tr>
<th style={{ width: "80px" }}>Enable</th>
<th style={{ width: "160px" }}>Name</th>
<th style={{ width: "100px" }}>Type</th>
<th style={{ width: "120px" }}>Channel</th>
<th>Options</th>
<th style={{ width: "140px", textAlign: "right" }}></th>
</tr>
</thead>
<tbody>
{editing.map((ch, i) => (
<tr key={i}>
<td>
<Switch
checked={!ch.isDisabled}
onChange={(e) => {
updateChannel(i, { isDisabled: !e.currentTarget.checked });
}}
/>
</td>
<td>
<InputGroup
value={ch.name || ""}
onChange={(e) => {
updateChannel(i, { name: e.target.value });
}}
onBlur={() => {
if (ch.name === "") {
updateChannel(i, { name: `ch${i}` });
}
}}
/>
</td>
<td>
<HTMLSelect
value={ch.type}
onChange={(e) => {
updateChannel(i, { type: e.target.value as ChannelType });
}}
options={[
{ value: "GR", label: "GR" },
{ value: "BS", label: "BS" },
{ value: "CS", label: "CS" },
{ value: "SKY", label: "SKY" }
]}
/>
</td>
<td>
<InputGroup
value={ch.channel || ""}
onChange={(e) => {
updateChannel(i, { channel: e.target.value });
}}
onBlur={() => {
if (ch.channel === "") {
updateChannel(i, { channel: "0" });
}
}}
/>
</td>
<td>
<div className="channel-options-grid">
<FormGroup label="Service ID" style={{ width: "90px", marginBottom: 0 }}>
<InputGroup
placeholder="SID"
value={`${ch.serviceId || ""}`}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
deleteChannelProperty(i, "serviceId");
} else if (/^[0-9]+$/.test(val)) {
const sid = parseInt(val, 10);
if (sid <= 65535 && sid > 0) {
updateChannel(i, { serviceId: sid });
}
}
}}
/>
</FormGroup>
<FormGroup label="TsmfRelTs" style={{ width: "90px", marginBottom: 0 }}>
<InputGroup
placeholder="TsmfRelTs"
value={`${ch.tsmfRelTs || ""}`}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
deleteChannelProperty(i, "tsmfRelTs");
} else if (/^[0-9]+$/.test(val)) {
const tsmfRelTs = parseInt(val, 10);
updateChannel(i, { tsmfRelTs });
}
}}
/>
</FormGroup>
<div className="cmd-vars-container">
<div className="cmd-vars-title">Command Vars</div>
<div className="cmd-vars-list">
{ch.commandVars && Object.entries(ch.commandVars).map(([key, value]) => (
<div key={key} className="cmd-var-pair">
<InputGroup
small
className="cmd-var-key"
value={key}
onChange={(e) => updateCommandVarKey(i, key, e.target.value)}
/>
<span className="cmd-var-separator">:</span>
<InputGroup
small
className="cmd-var-value"
value={`${value}`}
onChange={(e) => updateCommandVarValue(i, key, e.target.value)}
/>
<Button
small
minimal
intent="danger"
icon="cross"
onClick={() => removeCommandVar(i, key)}
/>
</div>
))}
<Button
small
minimal
intent="primary"
icon="plus"
text="Add Var"
onClick={() => addCommandVar(i)}
/>
</div>
</div>
</div>
</td>
<td>
<div className="controls-cell">
<Button
disabled={i === 0}
icon="chevron-up"
onClick={() => handleUp(i)}
minimal
/>
<Button
disabled={i === editing.length - 1}
icon="chevron-down"
onClick={() => handleDown(i)}
minimal
/>
<Button
icon="trash"
intent="danger"
onClick={() => handleRemove(i)}
minimal
/>
</div>
</td>
</tr>
))}
</tbody>
</HTMLTable>
</div>
{/* 保存確認ダイアログ */}
<Dialog
isOpen={showSaveDialog}
onClose={() => setShowSaveDialog(false)}
title="Save"
>
<DialogBody>
<p></p>
<p className="bp5-text-muted"></p>
</DialogBody>
<DialogFooter
actions={
<>
<Button onClick={() => setShowSaveDialog(false)}></Button>
<Button
intent="primary"
disabled={!hasChanges}
onClick={handleSave}
>
</Button>
</>
}
/>
</Dialog>
{/* スキャン設定ダイアログ */}
<Dialog
isOpen={showScanDialog}
onClose={() => setShowScanDialog(false)}
title="Channel Scan"
style={{ width: "450px" }}
>
<DialogBody>
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<FormGroup label="Channel Type">
<HTMLSelect
value={scanType}
onChange={(e) => {
const newType = e.target.value as ChannelType;
setScanType(newType);
switch (newType) {
case "GR":
setScanMinCh("13");
setScanMaxCh("62");
break;
case "BS":
setScanMinCh("1");
setScanMaxCh("23");
break;
case "CS":
setScanMinCh("2");
setScanMaxCh("24");
break;
}
}}
options={[
{ value: "GR", label: "GR" },
{ value: "BS", label: "BS" },
{ value: "CS", label: "CS" }
]}
/>
</FormGroup>
<div style={{ display: "flex", gap: "16px" }}>
<FormGroup label="Min Channel" style={{ flex: 1 }}>
<InputGroup
value={scanMinCh}
onChange={(e) => setScanMinCh(e.target.value)}
/>
</FormGroup>
<FormGroup label="Max Channel" style={{ flex: 1 }}>
<InputGroup
value={scanMaxCh}
onChange={(e) => setScanMaxCh(e.target.value)}
/>
</FormGroup>
</div>
<FormGroup
label="Skip Channels (comma separated integers)"
helperText="Enter channel numbers to skip. Range notation (e.g. 14-16) is supported."
>
<InputGroup
placeholder="Example: 13,14-16,18"
value={scanSkipCh}
onChange={(e) => {
const val = e.target.value;
if (val === "" || /^[0-9,\-]+$/.test(val)) {
setScanSkipCh(val);
}
}}
/>
</FormGroup>
{scanType === "BS" && (
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
<Switch
label="Use Subchannel Style (BS01_0)"
checked={scanUseSubCh}
onChange={(e) => setScanUseSubCh(e.currentTarget.checked)}
/>
{scanUseSubCh && (
<div style={{ display: "flex", gap: "16px" }}>
<FormGroup label="Min Subchannel" style={{ flex: 1 }}>
<InputGroup
value={scanMinSubCh}
onChange={(e) => setScanMinSubCh(e.target.value)}
/>
</FormGroup>
<FormGroup label="Max Subchannel" style={{ flex: 1 }}>
<InputGroup
value={scanMaxSubCh}
onChange={(e) => setScanMaxSubCh(e.target.value)}
/>
</FormGroup>
</div>
)}
</div>
)}
<Switch
label="Use Channel Name Format"
checked={scanChannelNameFormatEnabled}
onChange={(e) => setScanChannelNameFormatEnabled(e.currentTarget.checked)}
/>
{scanChannelNameFormatEnabled && (
<FormGroup
label="Channel Name Format"
helperText="Format to use for channel names. Supports placeholders like {ch}, {ch00}, {subch}."
>
<InputGroup
placeholder="Example: {ch}, BS{ch00}_{subch}"
value={scanChannelNameFormat}
onChange={(e) => setScanChannelNameFormat(e.target.value)}
/>
</FormGroup>
)}
<Switch
label="Auto Apply Results (Restart required)"
checked={scanAutoApply}
onChange={(e) => setScanAutoApply(e.currentTarget.checked)}
/>
<Switch
label="Set Disabled on Add"
checked={scanSetDisabledOnAdd}
onChange={(e) => setScanSetDisabledOnAdd(e.currentTarget.checked)}
/>
<Switch
label="Refresh (Update existing channels)"
checked={scanRefresh}
onChange={(e) => setScanRefresh(e.currentTarget.checked)}
/>
</div>
</DialogBody>
<DialogFooter
actions={
<>
<Button onClick={() => setShowScanDialog(false)}>Cancel</Button>
<Button intent="primary" onClick={startScan}>Start Scan</Button>
</>
}
/>
</Dialog>
{/* スキャン結果/ログダイアログ */}
<Dialog
isOpen={showScanResultDialog}
onClose={() => setShowScanResultDialog(false)}
title="Scan Results"
style={{ width: "600px" }}
>
<DialogBody>
{scanStatus && (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
{scanStatus.status === "completed" && (
<Callout intent="success" title="スキャン完了">
<div>: {scanStatus.newCount} | : {scanStatus.takeoverCount}</div>
</Callout>
)}
<div style={{
maxHeight: "300px",
overflowY: "auto",
border: "1px solid rgba(0,0,0,0.1)",
padding: "8px",
fontFamily: "monospace",
fontSize: "12px",
whiteSpace: "pre-wrap",
backgroundColor: "rgba(0,0,0,0.02)"
}}>
{scanStatus.scanLog && scanStatus.scanLog.length > 0 ? (
scanStatus.scanLog.join("\n")
) : (
<div></div>
)}
</div>
{scanStatus.status === "completed" && scanStatus.result && (
<Callout intent="primary">
</Callout>
)}
</div>
)}
</DialogBody>
<DialogFooter
actions={
<>
<Button onClick={() => setShowScanResultDialog(false)}></Button>
<Button
intent="primary"
onClick={applyScanResult}
disabled={scanStatus?.status !== "completed" || !scanStatus?.result}
>
</Button>
</>
}
/>
</Dialog>
</div>
);
};

198
web/src/routes/EPGView.tsx Normal file
View File

@@ -0,0 +1,198 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect, useMemo } from "react";
import { useParams } from "react-router-dom";
import { Alignment, Button, Navbar, Tabs, Tab, HTMLSelect, Breadcrumbs } from "@blueprintjs/core";
import { DateTime } from "luxon";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import { useLocalStorageState } from "../hooks/useWebStorageState";
import { ChannelType } from "../../../api";
import { WatchButton } from "../components/WatchButton";
import { EPGTable } from "../components/EPGTable";
export const EPGView: React.FC = () => {
console.debug("routes", "EPG");
const params = useParams();
const { navigate, searchParams } = state;
const [channelType, setChannelType] = useLocalStorageState<ChannelType>("EPG.channelType", "GR");
const [programId, setProgramId] = useState<number>(null);
const [time, setTime] = useState<number>(null);
const globalServiceId = parseInt(params.globalServiceId, 10) || null;
const programIdQuery = searchParams.get("programId");
const typeQuery = searchParams.get("type");
const dateQuery = searchParams.get("date");
const timeQuery = searchParams.get("time");
const now = DateTime.now();
const isoDate = /^\d{4}-\d{2}-\d{2}$/.test(dateQuery) ? dateQuery : now.toISODate();
const startDate = now.startOf("day");
const endDate = startDate.plus({ days: 7 });
let date = DateTime.fromISO(isoDate);
if (globalServiceId) {
date = date.set({ day: now.day });
}
if (typeQuery) {
if (typeQuery === "ALL") {
if (channelType !== null) {
setChannelType(null);
return;
}
} else if (typeQuery !== channelType) {
setChannelType(typeQuery as ChannelType);
return;
}
} else if (!globalServiceId && !programIdQuery && !timeQuery) {
let to = `?type=${channelType || "ALL"}&date=${isoDate}`;
setTimeout(() => navigate(to, { replace: true }), 0);
return;
}
let hasRemovedTempParams = false;
if (programIdQuery) {
setProgramId(parseInt(programIdQuery, 10));
searchParams.delete("programId");
hasRemovedTempParams = true;
}
if (timeQuery) {
setTime(parseInt(timeQuery, 10));
searchParams.delete("time");
hasRemovedTempParams = true;
}
if (hasRemovedTempParams) {
setTimeout(() => navigate(`?${searchParams.toString()}`, { replace: true }), 0);
return;
}
if (!globalServiceId) {
ui.setTitle("EPG");
}
const toolbarTabs: JSX.Element[] = [];
if (date >= startDate && date <= endDate && !globalServiceId) {
for (let i = 0; i <= 7; i++) {
const cur = startDate.plus({ days: i });
const id = `epg-toolbar-tabs-item-${cur.toISODate()}`;
const d = i === 0 ? cur.toFormat("M/d") : cur.toFormat("d");
const c = cur.toFormat("ccc");
toolbarTabs.push(<Tab key={id} id={id} title={<>{d}<sup className={`color-dow-${cur.weekday}`}>{c}</sup></>} />);
}
} else {
const id = `epg-toolbar-tabs-item-${date.toISODate()}`;
const title = date.toFormat("yyyy/MM/dd(ccc)");
toolbarTabs.push(<Tab key={id} id={id} title={title} />);
}
const showTodayButton = (!globalServiceId && toolbarTabs.length === 1) || (globalServiceId && date.toMillis() !== startDate.toMillis());
return (
<div className="route" id="route-epg">
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
{globalServiceId
? <Breadcrumbs items={[
{ text: "EPG 番組表", onClick: () => {
let to = `/epg?type=${channelType || ""}`;
if (isoDate) {
to += `&date=${isoDate}`;
}
if (time) {
to += `&time=${time}`;
}
navigate(to)
} },
{ text: "週間" },
{ text: "放送サービス...", className: "heading-title bp5-skeleton" }
]} />
: "EPG 番組表"
}
</Navbar.Heading>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
{globalServiceId && (
<>
<WatchButton variant="outlined" popoverPlacement="bottom-start" globalServiceId={globalServiceId} />
</>
)}
{!globalServiceId && (
<>
{showTodayButton && (
<Button variant="minimal" icon="reset" text="今日" onClick={() => {
navigate("?", { replace: true });
}} />
)}
<Tabs id="epg-toolbar-tabs"
selectedTabId={`epg-toolbar-tabs-item-${isoDate}`}
onChange={(tabId: string) => {
const to = tabId.replace(/^epg-toolbar-tabs-item-/, "");
if (isoDate !== to) {
navigate(`?date=${to}&type=${channelType || "ALL"}`);
}
}}
>
{toolbarTabs}
</Tabs>
<Navbar.Divider />
<HTMLSelect
className="bp5-outlined"
options={[
{ value: "ALL", label: "全波" },
{ value: "GR", label: "地上" },
{ value: "BS" },
{ value: "CS" },
{ value: "SKY" },
]}
value={channelType || ""}
onChange={event => {
ui.blur();
const type = event.currentTarget.value;
let to = `/epg?type=${type}`;
if (isoDate) {
to += `&date=${isoDate}`;
}
navigate(to);
}}
/>
</>
)}
</Navbar.Group>
</Navbar>
<div className="content no-margin">
{globalServiceId
? <EPGTable date={date} defaultTime={time} globalServiceId={globalServiceId} />
: <EPGTable date={date} defaultTime={time} defaultProgramId={programId} channelType={channelType} />
}
</div>
</div>
);
};

View File

@@ -0,0 +1,238 @@
@use "~@blueprintjs/colors/lib/scss/colors"
@use "../vars"
#route-home-view
.home-container
display: flex
flex-direction: column
gap: 20px
.home-section
flex-shrink: 0
background: rgba(colors.$light-gray5, 0.2)
&:last-child
margin-bottom: 20px
.bp5-dark &
background: rgba(colors.$black, 0.2)
.home-section-content
padding: 15px
.bp5-section-header
min-height: 40px
// --- Status Section ---
.status-grid
display: grid
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))
gap: 10px
.status-item
display: flex
flex-direction: column
gap: 5px
padding: 5px 10px
border-radius: 4px
.status-label
font-size: 12px
color: colors.$gray3
font-weight: 500
.bp5-dark &
color: colors.$gray5
.status-value
font-size: 14px
font-family: vars.$font-ui
word-break: break-all
// --- Services Section ---
.service-filters
display: flex
gap: 10px
margin-bottom: 10px
.service-grid
display: grid
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))
gap: 5px
.service-item
display: flex
align-items: center
gap: 5px
padding: 5px 10px
border-radius: 4px
transition: background 0.15s
&:hover
background: colors.$light-gray3
.bp5-dark &
background: colors.$dark-gray2
.bp5-icon
vertical-align: baseline
.service-item-main
display: flex
align-items: center
gap: 5px
flex: 1
min-width: 0
color: inherit
text-decoration: none
cursor: pointer
.service-logo
width: 32px
height: auto
border-radius: 1px
flex-shrink: 0
.service-name
flex: 1
min-width: 0
font-size: 13px
font-weight: 500
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
.service-epg-status
flex-shrink: 0
.service-play
flex-shrink: 0
padding: 4px
border-radius: 4px
cursor: pointer
transition: background 0.15s
&:hover
background: colors.$light-gray3
.bp5-dark &
background: colors.$dark-gray2
.service-tooltip
display: flex
flex-direction: column
gap: 5px
font-size: 12px
font-family: vars.$font-ui
line-height: 1.5
.bp5-dark &
color: colors.$gray1
// --- Tuners Section ---
.tuner-tree
.bp5-tree-node
.bp5-tree-node-content
height: 35px
gap: 10px
.bp5-tree-node-content:hover
background: none
.bp5-tree-node-label
display: flex
align-items: center
gap: 10px
.tuner-label
font-size: 13px
font-weight: 600
white-space: nowrap
.tuner-device-info
display: flex
align-items: center
gap: 10px
font-size: 12px
font-family: vars.$font-ui
color: colors.$gray1
white-space: nowrap
.bp5-button
margin-left: 4px
padding: 0 4px
min-width: unset
min-height: unset
.bp5-dark &
color: colors.$gray2
.tuner-user-info
display: flex
align-items: center
gap: 16px
font-size: 12px
font-family: vars.$font-ui
white-space: nowrap
.tuner-user-info-item
display: inline-flex
align-items: center
gap: 4px
.stream-info-link
color: colors.$blue3
text-decoration: none
cursor: pointer
&:hover
text-decoration: underline
// --- EPG Ready Color ---
.color-epg-ready
color: colors.$green3 !important
// Stream Info Dialog (rendered via Portal outside #route-home-view)
.stream-info-table
width: 100%
table-layout: fixed
font-size: 12px
border-collapse: collapse
th, td
padding: 4px 12px
th:nth-child(1), td:nth-child(1)
text-align: left
th:nth-child(2), td:nth-child(2),
th:nth-child(3), td:nth-child(3)
text-align: right
font-variant-numeric: tabular-nums
td.color-danger
color: colors.$red3
// Dark mode adjustments
body.bp5-dark
#route-home-view
.service-item
&:hover
background: colors.$dark-gray2
.service-tooltip
color: colors.$gray1
.tuner-device-info
color: colors.$gray2
.color-epg-ready
color: colors.$green4 !important
.stream-info-link
color: colors.$blue4
.stream-info-table td.color-danger
color: colors.$red4

583
web/src/routes/HomeView.tsx Normal file
View File

@@ -0,0 +1,583 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect, useCallback } from "react";
import { Link } from "react-router-dom";
import {
Alignment,
Breadcrumbs,
Button,
Checkbox,
Dialog,
DialogBody,
DialogFooter,
Icon,
Navbar,
NonIdealState,
Section,
Spinner,
Tooltip,
Tree,
TreeNodeInfo
} from "@blueprintjs/core";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import { Service, Status, StreamInfo, TunerDevice } from "../../../api.d";
import "./HomeView.sass";
const summarizeStreamInfo = (streamInfo: StreamInfo): string => {
if (!streamInfo) {
return "-";
}
let packets = 0;
let drops = 0;
for (const pid in streamInfo) {
packets += streamInfo[pid].packet;
drops += streamInfo[pid].drop;
}
return `Dropped Pkts: ${drops} / ${packets}`;
};
const isEmptyStreamInfo = (streamInfo: StreamInfo): boolean => {
if (!streamInfo) {
return true;
}
return Object.keys(streamInfo).length === 0;
};
// --- Status Section ---
const StatusSection: React.FC<{ status: Status }> = ({ status }) => {
if (!status) {
return <Spinner size={20} />;
}
const dockerStat = status.process?.env?.DOCKER === "YES" ? " 🐋" : "";
const items: { label: string; text: string }[] = [
{ label: "Platform", text: `${status.process?.platform} (${status.process?.arch})${dockerStat}` },
{ label: "Rust Version", text: status.process?.versions?.rust },
{ label: "Memory (RSS)", text: `${Math.round(status.process?.memoryUsage?.rss / 1024 / 1024)} MB` },
{ label: "EPG Gathering Network IDs", text: status.epg.gatheringNetworks.map(id => `0x${id.toString(16).toUpperCase()}`).join(", ") || "-" },
{ label: "EPG Stored Events", text: `${status.epg.storedEvents} Events` },
{ label: "TunerDevice Streams", text: `${status.streamCount.tunerDevice}` },
{ label: "TSFilter Streams", text: `${status.streamCount.tsFilter}` },
{ label: "Decoder Streams", text: `${status.streamCount.decoder}` },
{ label: "RPC Connections", text: `${status.rpcCount}` }
];
return (
<div className="status-grid">
{items.map((item, i) => (
<div key={i} className="status-item">
<span className="status-label">{item.label}</span>
<span className="status-value">{item.text}</span>
</div>
))}
</div>
);
};
// --- Services Section ---
const ServicesSection: React.FC<{
status: Status;
services: Service[];
allowPNA: boolean;
tsplayEndpoint: string;
}> = ({ status, services, allowPNA, tsplayEndpoint }) => {
const [showDTV, setShowDTV] = useState<boolean>(true);
const [showData, setShowData] = useState<boolean>(false);
const [showOthers, setShowOthers] = useState<boolean>(false);
const filteredServices = services.filter(service => {
if (service.type === 0x01 || service.type === 0xAD) {
return showDTV;
} else if (service.type === 0xC0) {
return showData;
}
return showOthers;
});
return (
<>
<div className="service-filters">
<Checkbox
label="DTV"
checked={showDTV}
onChange={() => setShowDTV(!showDTV)}
inline
/>
<Checkbox
label="Data"
checked={showData}
onChange={() => setShowData(!showData)}
inline
/>
<Checkbox
label="Others"
checked={showOthers}
onChange={() => setShowOthers(!showOthers)}
inline
/>
</div>
<div className="service-grid">
{filteredServices.map((service) => (
<Tooltip
key={service.id}
content={
<div className="service-tooltip">
<div>#{service.id}</div>
<div>SID: 0x{service.serviceId.toString(16).toUpperCase()} ({service.serviceId})</div>
<div>NID: 0x{service.networkId.toString(16).toUpperCase()} ({service.networkId})</div>
<div>Type: 0x{service.type.toString(16).toUpperCase()} ({service.type})</div>
<div>Channel: {service.channel?.type} / {service.channel?.channel}</div>
</div>
}
placement="bottom"
hoverOpenDelay={300}
>
<div className="service-item">
<Link className="service-item-main" to={`/epg/services/${service.id}`}>
{service.hasLogoData && (
<img
className="service-logo"
src={`/api/services/${service.id}/logo`}
alt=""
/>
)}
<span className="service-name">{service.name}</span>
<span className="service-epg-status">
{
status?.epg.gatheringNetworks.includes(service.networkId) && <Icon icon="refresh" className="color-warning" size={12} /> ||
service.epgReady && <Icon icon="tick" className="color-epg-ready" size={12} /> ||
<Icon icon="time" className="bp5-text-muted" size={12} />
}
</span>
</Link>
{service.type === 0x01 && allowPNA && tsplayEndpoint && (
<span
className="service-play"
onClick={(e) => {
e.stopPropagation();
window.open(
`${tsplayEndpoint}#${location.protocol}//${location.host}/api/services/${service.id}/stream?decode=1`,
"_blank",
"popup"
);
}}
title="TSPlay (Experimental)"
>
<Icon icon="play" intent="primary" size={12} />
</span>
)}
</div>
</Tooltip>
))}
</div>
</>
);
};
// --- Stream Info Table (for dialog) ---
const StreamInfoTable: React.FC<{
userId: string;
tuners: TunerDevice[];
initialInfo: StreamInfo;
}> = ({ userId, tuners, initialInfo }) => {
let currentInfo = initialInfo;
for (const tuner of tuners) {
const user = tuner.users.find(u => u.id === userId);
if (user?.streamInfo) {
currentInfo = user.streamInfo;
break;
}
}
const entries = Object.entries(currentInfo || {});
if (entries.length === 0) {
return <NonIdealState icon="info-sign" description="No stream info available." />;
}
return (
<table className="bp5-html-table bp5-html-table-striped bp5-html-table-condensed stream-info-table">
<thead>
<tr>
<th>PID</th>
<th className="numeric">Packets</th>
<th className="numeric">Drops</th>
</tr>
</thead>
<tbody>
{entries.map(([pid, data]) => (
<tr key={pid}>
<td>{pid}</td>
<td className="numeric">{data.packet.toLocaleString()}</td>
<td className={`numeric${data.drop > 0 ? " color-danger" : ""}`}>{data.drop.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
);
};
// --- Tuners Section ---
const TunersSection: React.FC<{
tuners: TunerDevice[];
}> = ({ tuners }) => {
const [killTarget, setKillTarget] = useState<number>(null);
const [tunersEx, setTunersEx] = useState<TunerDevice[]>([]);
const [streamDetail, setStreamDetail] = useState<{ userId: string; info: StreamInfo }>(null);
// get streamInfo periodically
useEffect(() => {
const interval = setInterval(async () => {
if (document.hidden) {
return;
}
try {
const result = await (await fetch("/api/tuners")).json();
setTunersEx(result);
} catch (e) {
console.warn(e);
}
}, 1000 * 5);
return () => clearInterval(interval);
}, []);
// merge streamInfo from tunersEx into tuners
const mergedTuners = tuners.map(tuner => {
const tunerEx = tunersEx.find(t => t.index === tuner.index);
if (tunerEx) {
return {
...tuner,
users: tuner.users.map(user => {
const userEx = tunerEx.users.find(u => u.id === user.id);
if (userEx?.streamInfo) {
return { ...user, streamInfo: userEx.streamInfo };
}
return user;
})
};
}
return tuner;
});
const treeNodes: TreeNodeInfo[] = mergedTuners.map((tuner) => {
const tunerLabel = `#${tuner.index}: ${tuner.name} (${tuner.types.join(", ")})`;
const hasUsers = tuner.users.length > 0;
let tunerIcon: TreeNodeInfo["icon"];
if (tuner.isFault) {
tunerIcon = <Icon icon="error" intent="danger" />;
} else if (!tuner.isAvailable) {
tunerIcon = <Icon icon="disable" className="bp5-text-muted" />;
} else if (tuner.isUsing) {
tunerIcon = <Icon icon="dot" className="color-epg-ready" />;
} else {
tunerIcon = <Icon icon="dot" className="bp5-text-muted" />;
}
const childNodes: TreeNodeInfo[] = [];
// device info node
if (tuner.command || tuner.pid) {
childNodes.push({
id: `tuner-${tuner.index}-device`,
icon: <Icon icon="console" className="bp5-text-muted" />,
label: (
<span className="tuner-device-info">
<span>{tuner.command || "-"}</span>
{tuner.pid ? <span className="bp5-text-muted"> (pid={tuner.pid})</span> : null}
{tuner.command && (
<Button
variant="minimal"
icon="cross"
intent="danger"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setKillTarget(tuner.index);
}}
title="Kill Tuner Process..."
/>
)}
</span>
),
hasCaret: false
});
}
// user nodes
for (let i = 0; i < tuner.users.length; i++) {
const user = tuner.users[i];
const isMirakurun = /Mirakurun/.test(user.id);
const userInfoItems: JSX.Element[] = [
<span key="priority" className="tuner-user-info-item">
<Icon icon="sort" className="bp5-text-muted" size={12} />
<span>{user.priority}</span>
</span>,
<span key="user" className="tuner-user-info-item">
<Icon icon={isMirakurun ? "cog" : "person"} className="bp5-text-muted" size={12} />
<span>{user.id}</span>
</span>,
<span key="ch" className="tuner-user-info-item">
<Icon icon="mobile-video" className="bp5-text-muted" size={12} />
<span>{user.streamSetting?.channel?.type} / {user.streamSetting?.channel?.channel}</span>
</span>,
<span key="sid" className="tuner-user-info-item">
<Icon icon="filter" className="bp5-text-muted" size={12} />
<span>{user.streamSetting?.serviceId ? `0x${user.streamSetting.serviceId.toString(16).toUpperCase()} (${user.streamSetting.serviceId})` : "-"}</span>
</span>
];
// stream info
if (!isEmptyStreamInfo(user.streamInfo)) {
userInfoItems.push(
<span key="stream" className="tuner-user-info-item">
<Icon icon="cube" className="bp5-text-muted" size={12} />
<a
className="stream-info-link"
onClick={(e) => {
e.stopPropagation();
setStreamDetail({ userId: user.id, info: user.streamInfo });
}}
>
{summarizeStreamInfo(user.streamInfo)}
</a>
</span>
);
}
childNodes.push({
id: `tuner-${tuner.index}-user-${i}`,
label: <span className="tuner-user-info">{userInfoItems}</span>,
hasCaret: false
});
}
return {
id: `tuner-${tuner.index}`,
icon: tunerIcon,
label: <span className="tuner-label">{tunerLabel}</span>,
isExpanded: hasUsers || !!tuner.command,
childNodes: childNodes.length > 0 ? childNodes : undefined,
hasCaret: childNodes.length > 0
};
});
const handleNodeCollapse = useCallback((_node: TreeNodeInfo) => {
// Tree is stateless; for now we allow expand/collapse via Tree's own behavior
}, []);
const handleNodeExpand = useCallback((_node: TreeNodeInfo) => {
// Tree is stateless
}, []);
return (
<>
{mergedTuners.length === 0 ? (
<Spinner size={20} />
) : (
<Tree
contents={treeNodes}
onNodeCollapse={handleNodeCollapse}
onNodeExpand={handleNodeExpand}
className="tuner-tree"
/>
)}
{/* Kill Tuner Process Dialog */}
<Dialog
isOpen={killTarget !== null}
onClose={() => setKillTarget(null)}
title="Kill Tuner Process"
icon="warning-sign"
>
<DialogBody>
<p>Do you want to kill this running tuner process?</p>
</DialogBody>
<DialogFooter
actions={
<>
<Button
text="Cancel"
onClick={() => setKillTarget(null)}
/>
<Button
intent="danger"
text="Kill"
onClick={() => {
(async () => {
await fetch(`/api/tuners/${killTarget}/process`, { method: "DELETE" });
})();
setKillTarget(null);
}}
/>
</>
}
/>
</Dialog>
{/* Stream Info Detail Dialog */}
<Dialog
isOpen={!!streamDetail}
onClose={() => setStreamDetail(null)}
title="Stream Info"
icon="cube"
style={{ width: 500 }}
>
<DialogBody>
{streamDetail && (
<>
<p className="bp5-text-muted">{streamDetail.userId}</p>
<StreamInfoTable
userId={streamDetail.userId}
tuners={tunersEx}
initialInfo={streamDetail.info}
/>
</>
)}
</DialogBody>
<DialogFooter
actions={
<Button
text="Close"
onClick={() => setStreamDetail(null)}
/>
}
/>
</Dialog>
</>
);
};
// --- HomeView ---
export const HomeView: React.FC = () => {
console.debug("routes", "HomeView");
ui.setTitle("Home");
const [status, setStatus] = useState<Status>(state.status);
const [services, setServices] = useState<Service[]>(state.services);
const [tuners, setTuners] = useState<TunerDevice[]>(state.tuners);
const [allowPNA, setAllowPNA] = useState<boolean>(false);
const [tsplayEndpoint, setTsplayEndpoint] = useState<string>("");
useEffect(() => {
// fetch server config
(async () => {
try {
if (!state.serverConfig) {
await state.fetchServerConfig();
}
if (state.serverConfig) {
setAllowPNA(state.serverConfig.allowPNA);
setTsplayEndpoint(state.serverConfig.tsplayEndpoint);
}
} catch (e) {
console.error(e);
}
})();
}, []);
useEffect(() => {
const onStatus = () => {
setStatus({ ...state.status });
};
state.on("status", onStatus);
const onServices = () => {
setServices([...state.services]);
};
state.on("services", onServices);
const onTuners = () => {
setTuners([...state.tuners]);
};
state.on("tuners", onTuners);
return () => {
state.off("status", onStatus);
state.off("services", onServices);
state.off("tuners", onTuners);
};
}, []);
const toolbar = (
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
<Breadcrumbs items={[
{
text: "Home"
}
]} />
</Navbar.Heading>
</Navbar.Group>
</Navbar>
);
return (
<div className="route" id="route-home-view">
{toolbar}
<div className="content">
<div className="home-container">
<Section className="home-section" title="Status" icon="dashboard" compact>
<div className="home-section-content">
<StatusSection status={status} />
</div>
</Section>
<Section
className="home-section"
title={`Services${services.length > 0 ? ` (${services.length})` : ""}`}
icon="globe-network"
compact
>
<div className="home-section-content">
<ServicesSection
status={status}
services={services}
allowPNA={allowPNA}
tsplayEndpoint={tsplayEndpoint}
/>
</div>
</Section>
<Section
className="home-section"
title={`Tuners${tuners.length > 0 ? ` (${tuners.length})` : ""}`}
icon="antenna"
compact
>
<div className="home-section-content">
<TunersSection tuners={tuners} />
</div>
</Section>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,48 @@
@use "~@blueprintjs/colors/lib/scss/colors"
#route-jobs-view
.content
display: flex
flex-direction: column
gap: 20px
> *
flex-shrink: 0
.bp5-section
background: rgba(colors.$light-gray5, 0.2)
.bp5-dark &
background: rgba(colors.$black, 0.2)
.bp5-section-header
.bp5-dark &
&:hover,
&:active
background: rgba(colors.$black, 0.1)
.bp5-collapse-body
display: flex
flex-direction: column
gap: 10px
padding: 10px
.bp5-navbar,
.bp5-navbar-group
height: 30px
.bp5-navbar
box-shadow: none
background: none
padding: 0 5px
.bp5-navbar-group
gap: 10px
> span
opacity: 0.75
&:hover .bp5-navbar-group > span
opacity: 1

510
web/src/routes/JobsView.tsx Normal file
View File

@@ -0,0 +1,510 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import { Alignment, Spinner, Breadcrumbs, Navbar, NonIdealState, NonIdealStateProps, Section, Button, Dialog, DialogBody, DialogFooter, Tooltip, Icon } from "@blueprintjs/core";
import { DateTime } from "luxon";
import { useLocalStorageState } from "../hooks/useWebStorageState";
import { LazyCaller } from "../modules/common";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import { JobScheduleItem, JobItem, Error as ApiError } from "../../../api.d";
import "./JobsView.sass";
export const JobsView: React.FC = () => {
console.debug("routes", "JobsView");
const [nonIdealState, setNonIdealState] = useState<NonIdealStateProps | null>(null);
const [reload, setReload] = useState<number>(0);
const [jobScheduleIsOpen, setJobScheduleIsOpen] = useLocalStorageState<boolean>("JobsView.jobScheduleIsOpen", true);
const [queuedIsOpen, setQueuedIsOpen] = useLocalStorageState<boolean>("JobsView.queuedIsOpen", true);
const [standbyIsOpen, setStandbyIsOpen] = useLocalStorageState<boolean>("JobsView.standbyIsOpen", true);
const [runningIsOpen, setRunningIsOpen] = useLocalStorageState<boolean>("JobsView.runningIsOpen", true);
const [finishedIsOpen, setFinishedIsOpen] = useLocalStorageState<boolean>("JobsView.finishedIsOpen", true);
const [jobScheduleItems, setJobScheduleItems] = useState<JSX.Element[]>([]);
const [queuedJobItems, setQueuedJobItems] = useState<JSX.Element[]>([]);
const [standbyJobItems, setStandbyJobItems] = useState<JSX.Element[]>([]);
const [runningJobItems, setRunningJobItems] = useState<JSX.Element[]>([]);
const [finishedJobItems, setFinishedJobItems] = useState<JSX.Element[]>([]);
const [title, setTitle] = useState<string>("ジョブ");
// const isLoading = !programs && !error;
// Action dialog state
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const [dialogType, setDialogType] = useState<"run_schedule" | "abort_job" | "rerun_job" | null>(null);
const [selectedScheduleKey, setSelectedScheduleKey] = useState<string | null>(null);
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [isActionLoading, setIsActionLoading] = useState<boolean>(false);
const [actionError, setActionError] = useState<string | null>(null);
const isLoading = !state.jobs && !state.jobSchedules;
ui.setTitle(title, isLoading);
// API handlers for job operations
const runJobSchedule = async (key: string): Promise<boolean> => {
try {
const res = await fetch(`/api/job-schedules/${encodeURIComponent(key)}/run`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const errorData = await res.json() as ApiError;
setActionError(errorData.reason || `Error: ${res.status}`);
return false;
}
// Re-fetch jobs and job schedules
await state.fetchJobs();
await state.fetchJobSchedules();
setActionError(null);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(`リクエスト失敗: ${message}`);
return false;
}
};
const abortJob = async (jobId: string): Promise<boolean> => {
try {
const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}/abort`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const errorData = await res.json() as ApiError;
setActionError(errorData.reason || `Error: ${res.status}`);
return false;
}
// Re-fetch jobs
await state.fetchJobs();
setActionError(null);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(`リクエスト失敗: ${message}`);
return false;
}
};
const rerunJob = async (jobId: string): Promise<boolean> => {
try {
const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}/rerun`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const errorData = await res.json() as ApiError;
setActionError(errorData.reason || `Error: ${res.status}`);
return false;
}
// Re-fetch jobs
await state.fetchJobs();
setActionError(null);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(`リクエスト失敗: ${message}`);
return false;
}
};
// Dialog action handler
const handleConfirm = async () => {
setIsActionLoading(true);
let success = false;
try {
if (dialogType === "run_schedule" && selectedScheduleKey) {
success = await runJobSchedule(selectedScheduleKey);
} else if (dialogType === "abort_job" && selectedJobId) {
success = await abortJob(selectedJobId);
} else if (dialogType === "rerun_job" && selectedJobId) {
success = await rerunJob(selectedJobId);
}
if (success) {
setIsDialogOpen(false);
setDialogType(null);
setSelectedScheduleKey(null);
setSelectedJobId(null);
}
} finally {
setIsActionLoading(false);
}
};
const openDialog = (type: "run_schedule" | "abort_job" | "rerun_job", key?: string) => {
setActionError(null);
setDialogType(type);
if (type === "run_schedule" && key) {
setSelectedScheduleKey(key);
} else if (type !== "run_schedule" && key) {
setSelectedJobId(key);
}
setIsDialogOpen(true);
};
useEffect(() => {
const onUpdated = () => {
setReload(Date.now());
};
const onUpdatedLazy = new LazyCaller(0, 500, onUpdated);
state.on("jobs", onUpdatedLazy.caller);
state.on("jobSchedules", onUpdatedLazy.caller);
return () => {
state.off("jobs", onUpdatedLazy.caller);
state.off("jobSchedules", onUpdatedLazy.caller);
onUpdatedLazy.destroy();
}
}, []);
useEffect(() => {
if (isLoading) {
setTitle("ジョブ...");
setNonIdealState({
icon: <Spinner />,
title: "ロード中",
description: "ジョブを読み込んでいます..."
});
return;
}
setJobScheduleItems(state.jobSchedules.map(jobSchedule => createJobScheduleItemElement(jobSchedule)));
setQueuedJobItems(state.jobs.filter(job => job.status === "queued").map(job => createJobItemElement(job)));
setStandbyJobItems(state.jobs.filter(job => job.status === "standby").map(job => createJobItemElement(job)));
setRunningJobItems(state.jobs.filter(job => job.status === "running").map(job => createJobItemElement(job)));
setFinishedJobItems(state.jobs.filter(job => job.status === "finished").map(job => createJobItemElement(job)));
setNonIdealState(null);
return () => {
setNonIdealState(null);
};
}, [reload]);
// Helper functions to create job/schedule items with closures over openDialog
const createJobScheduleItemElement = (jobSchedule: JobScheduleItem) => {
return (
<Navbar key={jobSchedule.key}>
<Navbar.Group align={Alignment.START}>
<code className="bp5-code">
{jobSchedule.schedule}
</code>
<span>
{jobSchedule.job.name}
</span>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
<Button
variant="minimal"
intent="warning"
icon="play"
text="実行..."
onClick={() => openDialog("run_schedule", jobSchedule.key)}
/>
</Navbar.Group>
</Navbar>
);
};
const createJobItemElement = (job: JobItem) => {
const statusLabel = getJobStatusLabel(job);
const statusIcon = getJobStatusIcon(job);
const statusIntent = getJobStatusIntent(job);
// Build detail tooltip content
const detailLines: string[] = [
`ID: ${job.id}`,
`Key: ${job.key}`
];
if (job.retryMax) {
detailLines.push(`リトライ: ${job.retryCount}/${job.retryMax}`);
}
if (job.startedAt) {
detailLines.push(`開始: ${DateTime.fromMillis(job.startedAt).toFormat("yyyy/MM/dd HH:mm:ss")}`);
}
if (job.finishedAt) {
detailLines.push(`終了: ${DateTime.fromMillis(job.finishedAt).toFormat("yyyy/MM/dd HH:mm:ss")}`);
}
if (job.duration) {
const durationSec = Math.round(job.duration / 1000);
detailLines.push(`実行時間: ${durationSec}`);
}
if (job.hasFailed && job.error) {
detailLines.push(`エラー: ${job.error}`);
}
if (job.hasAborted) {
detailLines.push("状態: 中止済み");
}
if (job.hasSkipped) {
detailLines.push("状態: スキップ");
}
const detailTooltip = detailLines.join("\n");
return (
<Navbar key={job.id}>
<Navbar.Group align={Alignment.START}>
<Tooltip content={detailTooltip} position="right">
<span className="bp5-text-muted" style={{ cursor: "help" }} title="詳細">
{job.id.split(".").slice(-1)[0]}
</span>
</Tooltip>
<span title={job.key} style={{ marginLeft: "0.5rem" }}>
{job.name}
</span>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
<span className="bp5-text-muted" style={{ marginLeft: "0.5rem" }}>
<Icon icon={statusIcon} intent={statusIntent} />
<span style={{ marginLeft: "0.35rem" }}>{statusLabel}</span>
</span>
<Tooltip content={DateTime.fromMillis(job.updatedAt).toFormat("yyyy/MM/dd HH:mm:ss")}>
<span className="bp5-text-muted">
{DateTime.fromMillis(job.updatedAt).toRelative()}
</span>
</Tooltip>
{job.status !== "finished" && (
<Button
minimal
small
icon="stop"
intent="danger"
onClick={() => openDialog("abort_job", job.id)}
title="ジョブを中止リクエスト"
disabled={job.isAborting}
/>
)}
{job.status === "finished" && (
<Button
minimal
small
icon="refresh"
intent="primary"
onClick={() => openDialog("rerun_job", job.id)}
title="ジョブを再実行"
style={{ visibility: job.isRerunnable ? undefined : "hidden" }}
/>
)}
</Navbar.Group>
</Navbar>
);
};
return (
<div className="route" id="route-jobs-view">
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
<Breadcrumbs items={[
{
text: "ジョブ"
}
]} />
</Navbar.Heading>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
</Navbar.Group>
</Navbar>
<div className="content">
{!nonIdealState && <>
<Section
title="スケジュール"
icon="time"
collapsible
collapseProps={{
isOpen: jobScheduleIsOpen,
onToggle: () => setJobScheduleIsOpen(!jobScheduleIsOpen),
}}
compact
>
{jobScheduleItems.map((item) => item)}
</Section>
<Section
title="ジョブ"
icon="ninja"
collapsible
compact
>
{queuedJobItems.length > 0 && (
<Section
title={`queued (${queuedJobItems.length})`}
icon="time"
collapsible
collapseProps={{
isOpen: queuedIsOpen,
onToggle: () => setQueuedIsOpen(!queuedIsOpen),
}}
compact
>
{queuedJobItems.map((item) => item)}
</Section>
)}
{standbyJobItems.length > 0 && (
<Section
title={`standby (${standbyJobItems.length})`}
icon="stopwatch"
collapsible
collapseProps={{
isOpen: standbyIsOpen,
onToggle: () => setStandbyIsOpen(!standbyIsOpen),
}}
compact
>
{standbyJobItems.map((item) => item)}
</Section>
)}
{runningJobItems.length > 0 && (
<Section
title={`running (${runningJobItems.length})`}
icon="play"
collapsible
collapseProps={{
isOpen: runningIsOpen,
onToggle: () => setRunningIsOpen(!runningIsOpen),
}}
compact
>
{runningJobItems.map((item) => item)}
</Section>
)}
{finishedJobItems.length > 0 && (
<Section
title={`finished (${finishedJobItems.length})`}
icon="tick"
collapsible
collapseProps={{
isOpen: finishedIsOpen,
onToggle: () => setFinishedIsOpen(!finishedIsOpen),
}}
compact
>
{finishedJobItems.map((item) => item)}
</Section>
)}
</Section>
</>}
{nonIdealState && <>
<NonIdealState {...nonIdealState} />
</>}
</div>
{/* Confirmation Dialog */}
<Dialog
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
title={
dialogType === "run_schedule" ? "スケジュール実行" :
dialogType === "abort_job" ? "ジョブ中止" :
dialogType === "rerun_job" ? "ジョブ再実行" :
"確認"
}
canEscapeKeyClose={!isActionLoading}
>
<DialogBody>
{actionError && (
<div className="bp5-text-intent-danger" style={{ marginBottom: "16px" }}>
{actionError}
</div>
)}
<div>
{dialogType === "run_schedule" && "このスケジュールのジョブを実行してもよろしいですか?"}
{dialogType === "abort_job" && "このジョブの中止をリクエストしてもよろしいですか?"}
{dialogType === "rerun_job" && "このジョブを再実行してもよろしいですか?"}
</div>
</DialogBody>
<DialogFooter
actions={
<>
<Button text="キャンセル" onClick={() => setIsDialogOpen(false)} disabled={isActionLoading} />
<Button text="実行" intent="primary" onClick={handleConfirm} loading={isActionLoading} />
</>
}
/>
</Dialog>
</div>
);
};
function getJobStatusLabel(job: JobItem): string {
if (job.status === "queued") {
return "Queued...";
}
if (job.status === "standby") {
return "Standby...";
}
if (job.status === "running") {
return "Running...";
}
if (job.hasFailed) {
return `Failed${job.duration ? ` (${Math.round(job.duration / 1000)}s)` : ""}`;
}
if (job.hasAborted) {
return "Aborted";
}
if (job.hasSkipped) {
return "Skipped";
}
return `Finished${job.duration ? ` (${Math.round(job.duration / 1000)}s)` : ""}`;
}
function getJobStatusIcon(job: JobItem): any {
if (job.status === "queued") return "time";
if (job.status === "standby") return "stopwatch";
if (job.status === "running") return "play";
if (job.hasFailed) return "error";
if (job.hasAborted) return "cross";
if (job.hasSkipped) return "disable";
return "tick";
}
function getJobStatusIntent(job: JobItem): "none" | "primary" | "success" | "warning" | "danger" {
if (job.status === "running") return "primary";
if (job.status === "standby") return "warning";
if (job.hasFailed) return "danger";
if (job.hasAborted) return "warning";
return "success";
}

View File

@@ -0,0 +1,44 @@
@use "~@blueprintjs/colors/lib/scss/colors"
#route-logs-view
position: absolute
top: 0
right: 0
bottom: 0
left: 0
overflow: scroll
overflow-x: hidden
background: colors.$dark-gray2
.logs
padding: 8px 0
font-family: 'Courier New', Courier, monospace
font-size: 12px
color: colors.$gray3
> div
padding: 2px 16px
word-break: break-all
white-space: break-spaces
&:hover
background: colors.$dark-gray1
> div.latest
padding: 0
> div.level-debug
color: colors.$indigo5
> div.level-info
color: colors.$light-gray1
> div.level-warn
color: colors.$orange3
> div.level-error
color: colors.$red4
> div.level-fatal
color: colors.$white
background: colors.$red3

104
web/src/routes/LogsView.tsx Normal file
View File

@@ -0,0 +1,104 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect, useRef } from "react";
import { Client as RPCClient } from "jsonrpc2-ws";
import { JoinParams } from "../../types/rpc";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import "./LogsView.sass";
let _itemId = 0;
let logListCache: JSX.Element[] = [];
export const LogsView: React.FC = () => {
console.debug("routes", "LogsView");
ui.setTitle("ログ");
const [logList, setLogList] = useState<JSX.Element[]>([]);
const latestRef = useRef<HTMLDivElement>(null);
const onLogs = (lines: string[], unshift: boolean) => {
const newList: JSX.Element[] = [];
for (const line of lines) {
const parsed = line.match(/^[0-9.T:+-]+ ([a-z]+): /);
const level = parsed ? parsed[1] : "other";
newList.push(
<div key={`logs-list-item${_itemId}`} className={`level-${level}`}>
{line}
</div>
);
++_itemId;
}
if (unshift === true) {
logListCache = [...newList, ...logListCache].slice(-500);
} else {
logListCache = [...logListCache, ...newList].slice(-500);
}
setLogList(logListCache);
};
useEffect(() => {
const rpc = (state as any)._rpc as RPCClient;
const join = () => {
rpc.call("join", { rooms: ["logs"] } as JoinParams);
};
rpc.on("connected", join);
// 既に接続済みなら即座に join、そうでなければ connected イベントを待つ
if (rpc.isConnected()) {
join();
}
// 初期ログを取得
(async () => {
const lines: string = await (await fetch("/api/log")).text();
onLogs(lines.trim().split("\n"), true);
})();
// state の logs イベントをサブスクライブ
const onLogsEvent = (lines: string[], unshift: boolean) => {
onLogs(lines, unshift);
};
state.on("logs", onLogsEvent);
return () => {
rpc.off("connected", join);
if (rpc.isConnected()) {
rpc.call("leave", { rooms: ["logs"] } as JoinParams);
}
state.off("logs", onLogsEvent);
logListCache = [];
};
}, []);
useEffect(() => {
latestRef.current?.scrollIntoView();
});
return (
<div id="route-logs-view">
<div className="logs">
{logList}
<div className="latest" ref={latestRef}></div>
</div>
</div>
);
};

View File

@@ -0,0 +1,42 @@
@use "~@blueprintjs/colors/lib/scss/colors"
#route-program-view
.content
> *
margin: 20px 0
&:first-child
margin-top: 0
> .flex
display: flex
gap: 15px
align-items: center
> .component-date-time-range
color: colors.$gray2
.bp5-dark &
color: colors.$gray4
> p,
> .extended > p
max-width: 650px
white-space: pre-wrap
font-feature-settings: "palt" 1
> .extended
h4
font-size: 13px
font-weight: 600
margin: 15px 0 10px
p
margin-left: 15px
h4 + p
margin-top: 10px
> p.meta
font-size: 11px
opacity: 0.5

View File

@@ -0,0 +1,209 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import { Alignment, Button, Breadcrumbs, Navbar, NonIdealState } from "@blueprintjs/core";
import { DateTime } from "luxon";
import { getGlobalServiceId, getIdWithHex } from "../modules/common";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import * as regexp from "../modules/regexp";
import { clearSchedule, setSchedule } from "../modules/at";
import { Error, Program } from "../../../api.d";
import { ProgramTitle } from "../components/ProgramTitle";
import { WatchButton } from "../components/WatchButton";
import { DateTimeRange } from "../components/DateTimeRange";
import { ServiceLink } from "../components/ServiceLink";
import { ProgramGenres } from "../components/ProgramGenres";
import { ProgramAVInfo } from "../components/ProgramAVInfo";
import { ProgramRelatedLinks } from "../components/ProgramRelatedLinks";
import "./ProgramView.sass";
export const ProgramView: React.FC = () => {
console.debug("routes", "ProgramView");
const [reload, setReload] = useState(Date.now()); // リロード用
const [error, setError] = useState<Error>(null);
const [program, setProgram] = useState<Program>(null);
const { navigate } = state;
const params = useParams();
const programId = parseInt(params.programId, 10);
const now = Date.now();
const startTime = program?.startAt;
const endTime = program ? program.startAt + program.duration : null;
const isLoading = program === null && error === null;
const isOnAir = program && startTime <= now && endTime >= now;
const date = program && DateTime.fromMillis(program.startAt).set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
const time = program && DateTime.fromMillis(program.startAt).diff(date).toMillis();
const timeForServiceLink = (time > (1000 * 60 * 60 * 24 - 1000 * 60 * 5)) ? 1 : time;
useEffect(() => {
const onPrograms = (programs: Program[]) => {
const _program = programs.find(p => p.id === programId);
if (!_program) {
setError({
code: 404,
reason: "番組が見つかりません",
});
setProgram(null);
return;
}
setError(null);
setProgram(_program);
};
state.on("programs", onPrograms);
state.subscribePrograms(true);
return () => {
// // unsubscribe せずに差分更新を継続する
// state.unsubscribePrograms();
state.off("programs", onPrograms);
}
}, [programId]);
useEffect(() => {
if (!program) {
ui.setTitle("番組詳細...", true);
return;
}
ui.setTitle(program.name);
const schedules: ReturnType<typeof setSchedule>[] = [];
if (now <= startTime) {
schedules.push(setSchedule(startTime, () => setReload(Date.now())));
}
if (now <= endTime) {
schedules.push(setSchedule(endTime, () => setReload(Date.now())));
}
console.debug("ProgramView", program);
return () => {
for (const id of schedules) {
clearSchedule(id);
}
};
}, [program]);
return (
<div className="route" id="route-program-view">
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
<Breadcrumbs items={[
{
text: "EPG",
onClick: () => {
navigate(`/epg?date=${date.toISODate()}&time=${time}`)
}
},
{
className: isLoading ? "bp5-skeleton" : "",
text: isLoading ? "Loading................................." : (error ? "エラー" : (
program ? <ProgramTitle program={program} /> : <></>
))
}
]} />
</Navbar.Heading>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
{isLoading && <>
<Button className="bp5-skeleton" text="Loading............................" />
</>}
{program && <>
{isOnAir && <WatchButton variant="outlined" popoverPlacement="bottom-end" globalServiceId={getGlobalServiceId(program.networkId, program.serviceId)} />}
</>}
<Button
variant="outlined"
intent="primary"
icon="timeline-events"
text="番組表で表示"
onClick={() => {
let to = "/epg";
if (program) {
to += `?date=${date.toISODate()}`;
if (time) {
to += `&time=${time}`;
}
to += `&programId=${program.id}`;
}
navigate(to);
}}
/>
</Navbar.Group>
</Navbar>
<div className="content">
{program && <>
<div className="flex">
<ServiceLink
globalId={getGlobalServiceId(program.networkId, program.serviceId)}
date={date.toISODate()}
time={timeForServiceLink}
/>
<DateTimeRange start={program.startAt} end={program.startAt + program.duration} />
</div>
{program.description && (
<p className="description">{program.description.replace(regexp.enclosedAttributeUnicode, "")}</p>
)}
{program.extended && Object.entries(program.extended).map(([head, body]) => {
return <div className="extended" key={head}>
<h4>{head}</h4>
<p dangerouslySetInnerHTML={{ __html: ui.autoLink(body.trim()) }} />
</div>;
})}
{program.genres?.length > 0 && (
<ProgramGenres genres={program.genres} />
)}
{(program.video || program.audios) && (
<ProgramAVInfo video={program.video} audios={program.audios} />
)}
<p className="meta">
Program ID: {program.id}<br />
event_id: {getIdWithHex(program.eventId)}<br />
SID: {getIdWithHex(program.serviceId)}<br />
NID: {getIdWithHex(program.networkId)}
</p>
<ProgramRelatedLinks program={program} />
</>}
{error && <>
<NonIdealState
icon="warning-sign"
title={`${error.code} Error`}
description={error.reason || "エラーが発生しました"}
/>
</>}
</div>
</div>
);
};

View File

@@ -0,0 +1,21 @@
@use "~@blueprintjs/colors/lib/scss/colors"
#route-search-view
.content
display: flex
flex-direction: column
gap: 15px
> .bp5-card
max-width: 680px
background-color: colors.$light-gray4
.bp5-dark &
background-color: colors.$dark-gray4
.component-program-title
font-size: 18px
line-height: 1.3em
.name
font-weight: 400

View File

@@ -0,0 +1,160 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import { Alignment, Spinner, Breadcrumbs, Navbar, NonIdealState, NonIdealStateProps, Card } from "@blueprintjs/core";
import { LazyCaller, textMatch, normalizeText } from "../modules/common";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import { Program } from "../../../api.d";
import { ProgramCardBase } from "../components/ProgramCardBase";
import "./SearchView.sass";
export const SearchView: React.FC = () => {
console.debug("routes", "SearchView");
const [nonIdealState, setNonIdealState] = useState<NonIdealStateProps>(null);
const [programs, setPrograms] = useState<Program[]>(null);
const [result, setResult] = useState<JSX.Element[]>([]);
const [title, setTitle] = useState<string>("検索");
// const isLoading = !programs && !error;
const { navigate, searchParams } = state;
const query = searchParams.get("q") || null;
const isLoading = query && !programs;
ui.setTitle(title, isLoading);
useEffect(() => {
const onPrograms = () => {
const _programs = state.programs.filter(program => {
// 共有イベントを除外
if (program.relatedItems?.filter(item => item.type === "shared").length === 1) {
return false;
}
return true;
});
setPrograms(_programs);
};
const onProgramsLazy = new LazyCaller(0, 1000, onPrograms);
state.on("programs", onProgramsLazy.caller);
state.subscribePrograms(true);
return () => {
// // unsubscribe せずに差分更新を継続する
// state.unsubscribePrograms();
state.off("programs", onProgramsLazy.caller);
onProgramsLazy.destroy();
}
}, []);
useEffect(() => {
if (!query) {
setTitle("検索");
setResult([]);
setNonIdealState({
icon: "search",
title: "検索キーワードを入力してください",
description: "番組名、番組説明、サービス名、ジャンルなどで検索できます"
});
return;
}
if (!programs) {
setTitle("検索...");
setNonIdealState({
icon: <Spinner />,
title: "ロード中",
description: "番組一覧を読み込んでいます..."
});
return;
}
const q = normalizeText(query.trim()).toLowerCase();
const filteredPrograms: Program[] = [];
for (const p of programs) {
if (
(p.name && textMatch(p.name, q)) ||
(p.description && textMatch(p.description, q)) ||
(p.extended && textMatch(Object.entries(p.extended).flat().join(" "), q))
) {
filteredPrograms.push(p);
continue;
}
}
filteredPrograms.sort((a, b) => {
return a.startAt - b.startAt;
});
const _result: JSX.Element[] = filteredPrograms.map(createResultItem);
setTitle(`検索 "${query}" (${_result.length}件)`);
setResult(_result);
setNonIdealState(null);
return () => {
setNonIdealState(null);
};
}, [programs, query]);
return (
<div className="route" id="route-search-view">
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
<Breadcrumbs items={[
{
text: "EPG",
onClick: () => navigate("/epg")
},
{
text: "検索",
className: `heading-title ${isLoading ? "bp5-skeleton" : ""}`.trim(),
}
]} />
</Navbar.Heading>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
</Navbar.Group>
</Navbar>
<div className="content">
{!nonIdealState && result}
{nonIdealState && <>
<NonIdealState {...nonIdealState} />
</>}
</div>
</div>
);
};
function createResultItem(program: Program) {
return (
<Card key={program.id} elevation={0}>
<ProgramCardBase
program={program}
noAVInfo={true}
noActions={true}
/>
</Card>
);
}

View File

@@ -0,0 +1,86 @@
@use "~@blueprintjs/colors/lib/scss/colors"
#route-server-config-view
.content
display: flex
flex-direction: column
gap: 16px
padding-top: 20px
.config-section
flex-shrink: 0
background: rgba(colors.$light-gray5, 0.2)
&:last-child
margin-bottom: 20px
.bp5-dark &
background: rgba(colors.$black, 0.2)
.bp5-collapse-body
padding: 0
> .bp5-section-card
padding: 0
.bp5-section-header
min-height: 44px
.bp5-dark &
&:hover,
&:active
background: rgba(colors.$black, 0.1)
.config-form-grid
display: flex
flex-direction: column
.bp5-form-group
padding: 14px 20px 16px
margin: 0
border-top: 1px solid rgba(colors.$light-gray1, 0.65)
.bp5-dark &
border-top-color: rgba(colors.$dark-gray5, 0.7)
.bp5-label
margin-bottom: 5px
font-weight: 600
.bp5-html-select,
.bp5-input-group
width: min(100%, 200px)
.bp5-numeric-input
width: min(100%, 100px)
textarea.bp5-input
width: min(100%, 400px)
min-height: 88px
resize: vertical
.bp5-html-select select,
.bp5-input,
.bp5-numeric-input .bp5-input-group
width: 100%
.bp5-numeric-input
.bp5-button-group
flex-shrink: 0
.bp5-control
display: flex
align-items: center
min-height: 30px
margin-bottom: 0
.bp5-form-helper-text
max-width: 680px
margin-top: 6px
line-height: 1.45
.config-switch-group
.bp5-control
width: fit-content

View File

@@ -0,0 +1,584 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import {
Alignment,
Breadcrumbs,
Button,
Dialog,
DialogBody,
DialogFooter,
FormGroup,
HTMLSelect,
InputGroup,
Intent,
Navbar,
NonIdealState,
NumericInput,
Section,
Spinner,
Switch,
TextArea
} from "@blueprintjs/core";
import equal from "fast-deep-equal";
import { Validator as IPValidator } from "ip-num/Validator";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import { ConfigServer, LogLevel } from "../../../api.d";
import "./ServerConfigView.sass";
const configAPI = "/api/config/server";
const multilineConfigValue = (values?: string[] | null) => (values ?? []).join("\n");
const parseMultilineConfigValue = (value: string): string[] | null => {
const trimmedValue = value.trim();
if (trimmedValue === "") {
return null;
}
return trimmedValue.split("\n").map(line => line.trim());
};
export const ServerConfigView: React.FC = () => {
console.debug("routes", "ServerConfigView");
const [current, setCurrent] = useState<ConfigServer | null>(null);
const [editing, setEditing] = useState<ConfigServer | null>(null);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [saved, setSaved] = useState(false);
const [isLoading, setIsLoading] = useState(true);
ui.setTitle("サーバー設定", isLoading);
const [allowIPv4CidrRangesText, setAllowIPv4CidrRangesText] = useState("");
const [allowIPv6CidrRangesText, setAllowIPv6CidrRangesText] = useState("");
const [allowOriginsText, setAllowOriginsText] = useState("");
const syncMultilineConfigValues = (config: ConfigServer) => {
setAllowIPv4CidrRangesText(multilineConfigValue(config.allowIPv4CidrRanges));
setAllowIPv6CidrRangesText(multilineConfigValue(config.allowIPv6CidrRanges));
setAllowOriginsText(multilineConfigValue(config.allowOrigins));
};
useEffect(() => {
if (saved === true) {
setTimeout(() => {
// location.reload();
}, 500);
setSaved(false);
return;
}
(async () => {
try {
const res = await (await fetch(configAPI)).json();
console.log("ServerConfigView", "GET", configAPI, "->", res);
setEditing({ ...res });
setCurrent({ ...res });
syncMultilineConfigValues(res);
setIsLoading(false);
} catch (e) {
console.error(e);
setIsLoading(false);
}
})();
}, [saved]);
const docker = (state as any).status?.process?.env?.DOCKER === "YES";
const ipv6Ready = docker === false || (state as any).status?.process?.env?.DOCKER_NETWORK === "host";
let invalid = false;
let invalidEpgGatheringJobSchedule = false;
let invalidAllowIPv4CidrRanges = false;
let invalidAllowIPv6CidrRanges = false;
if (editing) {
if (editing.epgGatheringJobSchedule) {
if (!isValidCronExpression(editing.epgGatheringJobSchedule)) {
invalid = true;
invalidEpgGatheringJobSchedule = true;
}
}
if (editing.allowIPv4CidrRanges) {
for (const range of editing.allowIPv4CidrRanges) {
const [valid] = IPValidator.isValidIPv4CidrRange(range);
if (!valid) {
invalid = true;
invalidAllowIPv4CidrRanges = true;
break;
}
}
}
if (!invalid && editing.allowIPv6CidrRanges) {
for (const range of editing.allowIPv6CidrRanges) {
const [valid] = IPValidator.isValidIPv6CidrRange(range);
if (!valid) {
invalid = true;
invalidAllowIPv6CidrRanges = true;
break;
}
}
}
}
const hasChanges = editing !== null && current !== null && !equal(editing, current);
const handleCancel = () => {
if (current) {
setEditing({ ...current });
syncMultilineConfigValues(current);
}
};
const handleSave = async () => {
if (!editing) {
return;
}
setShowSaveDialog(false);
try {
const payload: { [key: string]: any } = { ...editing };
for (const key of Object.keys(payload)) {
if (payload[key] === null) {
delete payload[key];
}
}
console.log("ServerConfigView", "PUT", configAPI, "<-", payload);
await fetch(configAPI, {
method: "PUT",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(payload)
});
setSaved(true);
} catch (err) {
console.error(err);
}
};
const toolbar = (
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
<Breadcrumbs items={[
{
text: "サーバー設定"
}
]} />
</Navbar.Heading>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
<Button
minimal
intent="danger"
icon="undo"
text="Cancel"
disabled={!hasChanges}
onClick={handleCancel}
/>
<Button
intent="primary"
icon="saved"
text="Save"
disabled={!hasChanges || invalid}
onClick={() => setShowSaveDialog(true)}
/>
</Navbar.Group>
</Navbar>
);
if (isLoading || !editing) {
return (
<div className="route" id="route-server-config-view">
{toolbar}
<NonIdealState
icon={<Spinner />}
title="ロード中"
description="設定を読み込んでいます..."
/>
</div>
);
}
return (
<div className="route" id="route-server-config-view">
{toolbar}
<div className="content">
<Section
className="config-section"
title="Basic Config"
icon="settings"
compact
>
<div className="config-form-grid">
<FormGroup
label="Log Level"
labelFor="log-level"
helperText="ログ出力設定。通常運用では WARN を推奨します。問題が発生した時に変更し、ログを確認してください。"
>
<HTMLSelect
id="log-level"
value={editing.logLevel ?? 2}
onChange={(e) => {
setEditing({ ...editing, logLevel: parseInt(e.target.value, 10) as LogLevel });
}}
>
<option value="-1">FATAL (-1)</option>
<option value="0">ERROR (0)</option>
<option value="1">WARN (1)</option>
<option value="2">INFO (2)</option>
<option value="3">DEBUG (3)</option>
</HTMLSelect>
</FormGroup>
<FormGroup
label="Hostname"
labelFor="hostname"
helperText="Web UI にアクセスするためのホスト名を設定してください。任意のホスト名・ドメイン上のページからのアクセスを禁止しています。 (DNS Rebinding / CSRF 攻撃対策)"
>
<InputGroup
id="hostname"
value={editing.hostname ?? ""}
onChange={(e) => {
setEditing({ ...editing, hostname: e.target.value });
}}
/>
</FormGroup>
{ipv6Ready && (
<FormGroup
className="config-switch-group"
labelFor="disable-ipv6"
helperText="IPv6 の無効化 (よく分からない場合は ON)"
>
<Switch
id="disable-ipv6"
checked={editing.disableIPv6 ?? false}
label="Disable IPv6"
onChange={(e) => {
setEditing({ ...editing, disableIPv6: e.currentTarget.checked });
}}
/>
</FormGroup>
)}
</div>
</Section>
<Section
className="config-section"
title="Advanced Config"
icon="wrench"
compact
>
<div className="config-form-grid">
<FormGroup
label="Job Max Running"
labelFor="job-max-running"
helperText="同時実行できる最大ジョブ数"
>
<NumericInput
id="job-max-running"
value={editing.jobMaxRunning ?? ""}
placeholder="100"
min={1}
max={100}
onValueChange={(value, _) => {
if (value === null) {
delete editing.jobMaxRunning;
} else {
editing.jobMaxRunning = value;
}
setEditing({ ...editing });
}}
/>
</FormGroup>
<FormGroup
label="Job Max Standby"
labelFor="job-max-standby"
helperText="同時実行できる最大ジョブ準備数"
>
<NumericInput
id="job-max-standby"
value={editing.jobMaxStandby ?? ""}
placeholder="100"
min={1}
max={100}
onValueChange={(value, _) => {
if (value === null) {
delete editing.jobMaxStandby;
} else {
editing.jobMaxStandby = value;
}
setEditing({ ...editing });
}}
/>
</FormGroup>
<FormGroup
label="EPG Gathering Job Schedule (Cron)"
labelFor="epg-gathering-schedule"
helperText={invalidEpgGatheringJobSchedule ? "Cron expression is invalid." : "EPG 収集スケジュール (cron 風形式)"}
intent={invalidEpgGatheringJobSchedule ? Intent.DANGER : Intent.NONE}
>
<InputGroup
id="epg-gathering-schedule"
value={editing.epgGatheringJobSchedule ?? ""}
placeholder="20,50 * * * *"
onChange={(e) => {
editing.epgGatheringJobSchedule = e.target.value;
setEditing({ ...editing });
}}
intent={invalidEpgGatheringJobSchedule ? Intent.DANGER : Intent.NONE}
/>
</FormGroup>
<FormGroup
label="Max Buffer Bytes Before Ready (MB)"
labelFor="max-buffer-bytes"
helperText="番組イベント検出前の最大バッファサイズ (バイト) ※番組開始の頭が欠ける場合は増やす"
>
<NumericInput
id="max-buffer-bytes"
value={editing.maxBufferBytesBeforeReady ? Math.round(editing.maxBufferBytesBeforeReady / 1024 / 1024) : ""}
placeholder="8"
min={1}
max={64}
onValueChange={(value, _) => {
if (value === null) {
delete editing.maxBufferBytesBeforeReady;
} else {
editing.maxBufferBytesBeforeReady = value * 1024 * 1024;
}
setEditing({ ...editing });
}}
/>
</FormGroup>
<FormGroup
label="Event End Timeout (sec)"
labelFor="event-end-timeout"
helperText="番組イベント終了タイムアウト (ミリ秒) ※番組終了が誤判定される場合は長くする"
>
<NumericInput
id="event-end-timeout"
value={editing.eventEndTimeout ?? ""}
placeholder="1000"
min={1}
max={10000}
onValueChange={(value, _) => {
if (value === null) {
delete editing.eventEndTimeout;
} else {
editing.eventEndTimeout = value;
}
setEditing({ ...editing });
}}
/>
</FormGroup>
<FormGroup
className="config-switch-group"
labelFor="disable-eit-parsing"
helperText="EIT 解析の無効化 (EPG 関連機能が無効になります)"
intent={editing.disableEITParsing ? Intent.WARNING : Intent.NONE}
>
<Switch
id="disable-eit-parsing"
checked={editing.disableEITParsing ?? false}
label="Disable EIT Parsing ⚠️"
onChange={(e) => {
setEditing({ ...editing, disableEITParsing: e.currentTarget.checked ? true : undefined });
}}
/>
</FormGroup>
</div>
</Section>
<Section
className="config-section"
title="Network Config"
icon="globe-network"
compact
>
<div className="config-form-grid">
<FormGroup
className="config-form-wide"
label="Allow IPv4 CIDR Ranges ⚠️"
labelFor="allow-ipv4-cidrs"
helperText={invalidAllowIPv4CidrRanges ? "IPv4 CIDR range is invalid." : "アクセスを許可する IPv4 CIDR 範囲を1行に1つずつ指定 ⚠️ 最大限の注意が必要な設定です (グローバル IPv4 アドレスを指定しないでください)"}
intent={invalidAllowIPv4CidrRanges ? Intent.DANGER : Intent.NONE}
>
<TextArea
id="allow-ipv4-cidrs"
value={allowIPv4CidrRangesText}
onChange={(e) => {
const newValue = e.target.value;
setAllowIPv4CidrRangesText(newValue);
setEditing({ ...editing, allowIPv4CidrRanges: parseMultilineConfigValue(newValue) });
}}
rows={3}
intent={invalidAllowIPv4CidrRanges ? Intent.DANGER : Intent.NONE}
/>
</FormGroup>
<FormGroup
className="config-form-wide"
label="Allow IPv6 CIDR Ranges ⚠️"
labelFor="allow-ipv6-cidrs"
helperText={invalidAllowIPv6CidrRanges ? "IPv6 CIDR range is invalid." : "アクセスを許可する IPv6 CIDR 範囲を1行に1つずつ指定 ⚠️ 最大限の注意が必要な設定です (グローバル IPv6 アドレスを指定しないでください)"}
intent={invalidAllowIPv6CidrRanges ? Intent.DANGER : Intent.NONE}
>
<TextArea
id="allow-ipv6-cidrs"
value={allowIPv6CidrRangesText}
onChange={(e) => {
const newValue = e.target.value;
setAllowIPv6CidrRangesText(newValue);
setEditing({ ...editing, allowIPv6CidrRanges: parseMultilineConfigValue(newValue) });
}}
rows={3}
intent={invalidAllowIPv6CidrRanges ? Intent.DANGER : Intent.NONE}
/>
</FormGroup>
<FormGroup
className="config-form-wide"
label="Allow Origins ⚠️🧪"
labelFor="allow-origins"
helperText="アクセスを許可する Origin を1行に1つずつ指定"
>
<TextArea
id="allow-origins"
value={allowOriginsText}
onChange={(e) => {
const newValue = e.target.value;
setAllowOriginsText(newValue);
setEditing({ ...editing, allowOrigins: parseMultilineConfigValue(newValue) });
}}
rows={3}
/>
</FormGroup>
<FormGroup
className="config-switch-group"
labelFor="allow-pna"
helperText="Private Network Access / Local Network Access を許可 (ブラウザで保護されたコンテキストからのアクセスを認可できるようになります)"
>
<Switch
id="allow-pna"
checked={editing.allowPNA ?? true}
label="Allow PNA/LNA 🧪"
onChange={(e) => {
setEditing({ ...editing, allowPNA: e.currentTarget.checked });
}}
/>
</FormGroup>
</div>
</Section>
<Section
className="config-section"
title="Other Config"
icon="more"
compact
>
<div className="config-form-grid">
<FormGroup
label="TSPlay Endpoint 🧪"
labelFor="tsplay-endpoint"
helperText="TSPlay で使用するエンドポイント URL (保護されたコンテキスト)"
>
<InputGroup
id="tsplay-endpoint"
value={editing.tsplayEndpoint ?? ""}
onChange={(e) => {
const newValue = e.target.value.trim();
if (newValue === "") {
setEditing({ ...editing, tsplayEndpoint: null });
} else {
setEditing({ ...editing, tsplayEndpoint: newValue });
}
}}
/>
</FormGroup>
</div>
</Section>
</div>
{/* Save Confirmation Dialog */}
<Dialog
isOpen={showSaveDialog}
onClose={() => setShowSaveDialog(false)}
title="Save"
>
<DialogBody>
<p></p>
<p className="bp5-text-muted"></p>
</DialogBody>
<DialogFooter
actions={
<>
<Button onClick={() => setShowSaveDialog(false)}></Button>
<Button
intent="primary"
disabled={!hasChanges || invalid}
onClick={handleSave}
>
</Button>
</>
}
/>
</Dialog>
</div>
);
};
// (仮) src/Mirakurun/Job.ts にある関数と同じ
function isValidCronExpression(cronExpression: string): boolean {
const cronParts = cronExpression.split(" ");
if (cronParts.length !== 5) {
return false;
}
try {
// 各部分のパターンを定義
const patterns = [
/^(\*|([0-9]|[1-5][0-9])((-[0-9]|[1-5][0-9]))?)(\/([1-9]|[1-5][0-9]))?$/, // 分 (0-59)
/^(\*|([0-9]|1[0-9]|2[0-3])((-[0-9]|1[0-9]|2[0-3]))?)(\/([1-9]|1[0-9]|2[0-3]))?$/, // 時 (0-23)
/^(\*|([1-9]|[12][0-9]|3[01])((-[1-9]|[12][0-9]|3[01]))?)(\/([1-9]|[12][0-9]|3[01]))?$/, // 日 (1-31)
/^(\*|([1-9]|1[0-2])((-[1-9]|1[0-2]))?)(\/([1-9]|1[0-2]))?$/, // 月 (1-12)
/^(\*|([0-6])((-[0-6]))?)(\/([1-6]))?$/ // 曜日 (0-6)
];
// 各部分を検証
for (let i = 0; i < 5; i++) {
// カンマで区切られた値をすべて検証
const parts = cronParts[i].split(",");
for (const part of parts) {
if (part === "" || !patterns[i].test(part)) {
return false;
}
}
}
return true;
} catch (err) {
return false;
}
}

View File

@@ -0,0 +1,61 @@
@use "~@blueprintjs/colors/lib/scss/colors"
#route-tuners-config-view
.content
display: flex
flex-direction: column
gap: 16px
padding: 20px
overflow-y: auto
.tuner-table
width: 100%
border-collapse: collapse
th, td
vertical-align: top !important
padding: 12px 8px !important
td
.bp5-form-group
margin-bottom: 8px
&:last-child
margin-bottom: 0
.bp5-label
margin-bottom: 3px
font-weight: 600
font-size: 11px
color: colors.$gray1
.bp5-dark &
color: colors.$gray4
.types-checkboxes
display: flex
flex-direction: column
gap: 4px
margin-top: 6px
.bp5-control
margin-bottom: 0
.remote-mirakurun-group
display: flex
gap: 8px
align-items: flex-end
margin-bottom: 8px
.bp5-form-group
margin-bottom: 0 !important
.tuner-options-grid
display: flex
flex-direction: column
gap: 8px
.controls-cell
display: flex
gap: 4px
justify-content: flex-end
align-items: center

View File

@@ -0,0 +1,431 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import {
Alignment,
Breadcrumbs,
Button,
Checkbox,
Dialog,
DialogBody,
DialogFooter,
FormGroup,
InputGroup,
Navbar,
NonIdealState,
Spinner,
Switch,
HTMLTable
} from "@blueprintjs/core";
import equal from "fast-deep-equal";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import { ConfigTuners, ConfigTunersItem, ChannelType } from "../../../api.d";
import "./TunersConfigView.sass";
const configAPI = "/api/config/tuners";
const typesIndex = ["GR", "BS", "CS", "SKY"];
function sortTypes(types: ChannelType[]): ChannelType[] {
return types.sort((a, b) => typesIndex.indexOf(a) - typesIndex.indexOf(b));
}
export const TunersConfigView: React.FC = () => {
console.debug("routes", "TunersConfigView");
const [current, setCurrent] = useState<ConfigTuners | null>(null);
const [editing, setEditing] = useState<ConfigTuners | null>(null);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [saved, setSaved] = useState(false);
const [isLoading, setIsLoading] = useState(true);
ui.setTitle("チューナー設定", isLoading);
useEffect(() => {
if (saved === true) {
setTimeout(() => {
// Restart notification will be emitted in production when requested
}, 500);
setSaved(false);
return;
}
(async () => {
try {
const res = await (await fetch(configAPI)).json();
console.log("TunersConfigView", "GET", configAPI, "->", res);
setEditing(JSON.parse(JSON.stringify(res)));
setCurrent(JSON.parse(JSON.stringify(res)));
setIsLoading(false);
} catch (e) {
console.error(e);
setIsLoading(false);
}
})();
}, [saved]);
const hasChanges = editing !== null && current !== null && !equal(editing, current);
const handleCancel = () => {
if (current) {
setEditing(JSON.parse(JSON.stringify(current)));
}
};
const handleSave = async () => {
if (!editing) {
return;
}
setShowSaveDialog(false);
try {
console.log("TunersConfigView", "PUT", configAPI, "<-", editing);
await fetch(configAPI, {
method: "PUT",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(editing)
});
setSaved(true);
} catch (err) {
console.error(err);
}
};
const handleAddTuner = () => {
if (!editing) return;
const i = editing.length;
const newTuner: ConfigTunersItem = {
name: `adapter${i}`,
types: [],
command: `dvbv5-zap -a ${i} -c ./config/dvbconf-for-isdb/conf/dvbv5_channels_isdbs.conf -r -P <channel>`,
dvbDevicePath: `/dev/dvb/adapter${i}/dvr0`,
decoder: "arib-b25-stream-test",
isDisabled: true
};
setEditing([...editing, newTuner]);
};
const updateTuner = (index: number, updated: Partial<ConfigTunersItem>) => {
if (!editing) return;
const newEditing = [...editing];
newEditing[index] = { ...newEditing[index], ...updated };
setEditing(newEditing);
};
const deleteTunerProperty = (index: number, key: keyof ConfigTunersItem) => {
if (!editing) return;
const newEditing = [...editing];
const updated = { ...newEditing[index] };
delete updated[key];
newEditing[index] = updated;
setEditing(newEditing);
};
const handleUp = (i: number) => {
if (!editing || i === 0) return;
const newEditing = [...editing];
const temp = newEditing[i];
newEditing[i] = newEditing[i - 1];
newEditing[i - 1] = temp;
setEditing(newEditing);
};
const handleDown = (i: number) => {
if (!editing || i === editing.length - 1) return;
const newEditing = [...editing];
const temp = newEditing[i];
newEditing[i] = newEditing[i + 1];
newEditing[i + 1] = temp;
setEditing(newEditing);
};
const handleRemove = (i: number) => {
if (!editing) return;
const newEditing = [...editing];
newEditing.splice(i, 1);
setEditing(newEditing);
};
const toolbar = (
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
<Breadcrumbs items={[
{
text: "チューナー設定"
}
]} />
</Navbar.Heading>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
<Button
minimal
intent="success"
icon="add"
text="Add Tuner"
onClick={handleAddTuner}
/>
<Navbar.Divider />
<Button
minimal
intent="danger"
icon="undo"
text="Cancel"
disabled={!hasChanges}
onClick={handleCancel}
/>
<Button
intent="primary"
icon="saved"
text="Save"
disabled={!hasChanges}
onClick={() => setShowSaveDialog(true)}
/>
</Navbar.Group>
</Navbar>
);
if (isLoading || !editing) {
return (
<div className="route" id="route-tuners-config-view">
{toolbar}
<NonIdealState
icon={<Spinner />}
title="ロード中"
description="設定を読み込んでいます..."
/>
</div>
);
}
return (
<div className="route" id="route-tuners-config-view">
{toolbar}
<div className="content">
<HTMLTable className="tuner-table" striped interactive>
<thead>
<tr>
<th style={{ width: "80px" }}>Enable</th>
<th style={{ width: "180px" }}>Name</th>
<th style={{ width: "120px" }}>Types</th>
<th>Options</th>
<th style={{ width: "140px", textAlign: "right" }}></th>
</tr>
</thead>
<tbody>
{editing.map((tuner, i) => (
<tr key={i}>
<td>
<Switch
checked={!tuner.isDisabled}
onChange={(e) => {
updateTuner(i, { isDisabled: !e.currentTarget.checked });
}}
/>
</td>
<td>
<InputGroup
value={tuner.name || ""}
onChange={(e) => {
updateTuner(i, { name: e.target.value });
}}
/>
</td>
<td>
<div className="types-checkboxes">
{(["GR", "BS", "CS", "SKY"] as ChannelType[]).map((type) => {
const checked = tuner.types?.includes(type) ?? false;
return (
<Checkbox
key={type}
label={type}
checked={checked}
inline
onChange={(e) => {
let newTypes = [...(tuner.types || [])];
if (e.currentTarget.checked) {
newTypes.push(type);
newTypes = sortTypes(newTypes);
} else {
newTypes = newTypes.filter(t => t !== type);
}
updateTuner(i, { types: newTypes });
}}
/>
);
})}
</div>
</td>
<td>
<div className="tuner-options-grid">
{!tuner.remoteMirakurunHost && (
<>
<FormGroup label="Command">
<InputGroup
value={tuner.command || ""}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
deleteTunerProperty(i, "command");
} else {
updateTuner(i, { command: val });
}
}}
/>
</FormGroup>
<FormGroup label="DVB Device Path">
<InputGroup
value={tuner.dvbDevicePath || ""}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
deleteTunerProperty(i, "dvbDevicePath");
} else {
updateTuner(i, { dvbDevicePath: val });
}
}}
/>
</FormGroup>
</>
)}
{!tuner.command && (
<>
<div className="remote-mirakurun-group">
<FormGroup label="Remote Mirakurun Host" style={{ flex: 1 }}>
<InputGroup
value={tuner.remoteMirakurunHost || ""}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
deleteTunerProperty(i, "remoteMirakurunHost");
} else if (/^[0-9a-z\.]+$/.test(val)) {
updateTuner(i, { remoteMirakurunHost: val });
}
}}
/>
</FormGroup>
<FormGroup label="Port" style={{ width: "90px" }}>
<InputGroup
placeholder="40772"
value={`${tuner.remoteMirakurunPort || ""}`}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
deleteTunerProperty(i, "remoteMirakurunPort");
} else if (/^[0-9]+$/.test(val)) {
const port = parseInt(val, 10);
if (port <= 65535 && port > 0) {
updateTuner(i, { remoteMirakurunPort: port });
}
}
}}
/>
</FormGroup>
</div>
<div style={{ marginBottom: "8px" }}>
<Checkbox
label="Decode (Remote Mirakurun Decoder)"
checked={tuner.remoteMirakurunDecoder || false}
onChange={(e) => {
if (e.currentTarget.checked) {
updateTuner(i, { remoteMirakurunDecoder: true });
} else {
deleteTunerProperty(i, "remoteMirakurunDecoder");
}
}}
/>
</div>
</>
)}
{(!tuner.remoteMirakurunHost || !tuner.remoteMirakurunDecoder) && (
<FormGroup label="Decoder">
<InputGroup
value={tuner.decoder || ""}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
deleteTunerProperty(i, "decoder");
} else {
updateTuner(i, { decoder: val });
}
}}
/>
</FormGroup>
)}
</div>
</td>
<td>
<div className="controls-cell">
<Button
disabled={i === 0}
icon="chevron-up"
onClick={() => handleUp(i)}
minimal
/>
<Button
disabled={i === editing.length - 1}
icon="chevron-down"
onClick={() => handleDown(i)}
minimal
/>
<Button
icon="trash"
intent="danger"
onClick={() => handleRemove(i)}
minimal
/>
</div>
</td>
</tr>
))}
</tbody>
</HTMLTable>
</div>
{/* Save Confirmation Dialog */}
<Dialog
isOpen={showSaveDialog}
onClose={() => setShowSaveDialog(false)}
title="Save"
>
<DialogBody>
<p></p>
<p className="bp5-text-muted"></p>
</DialogBody>
<DialogFooter
actions={
<>
<Button onClick={() => setShowSaveDialog(false)}></Button>
<Button
intent="primary"
disabled={!hasChanges}
onClick={handleSave}
>
</Button>
</>
}
/>
</Dialog>
</div>
);
};

29
web/src/tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"alwaysStrict": true,
"target": "es2022",
"lib": [
"es2022",
"dom",
"dom.iterable"
],
"module": "esnext",
"moduleResolution": "bundler",
"removeComments": true,
"sourceMap": true,
"incremental": true,
"esModuleInterop": false,
"jsx": "react",
"allowSyntheticDefaultImports": true,
"outDir": "../dist"
},
"include": [
"./**/*.ts",
"./**/*.tsx",
"./custom.d.ts"
],
"exclude": [
"node_modules"
]
}

Some files were not shown because too many files have changed in this diff Show More