First commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/target
|
||||
/.idea
|
||||
/.vscode
|
||||
*.swp
|
||||
*.tmp
|
||||
node_modules/
|
||||
web/dist/
|
||||
32
AGENTS.md
Normal file
32
AGENTS.md
Normal 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
1688
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
Cargo.toml
Normal file
61
Cargo.toml
Normal 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
36
README.md
Normal 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
442
api.d.ts
vendored
Normal 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
1
config/channels.yml
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
10
config/server.yml
Normal file
10
config/server.yml
Normal 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
1
config/tuners.yml
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
22
crates/mirakurun-client/Cargo.toml
Normal file
22
crates/mirakurun-client/Cargo.toml
Normal 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
|
||||
180
crates/mirakurun-client/src/lib.rs
Normal file
180
crates/mirakurun-client/src/lib.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
28
crates/mirakurun-core/Cargo.toml
Normal file
28
crates/mirakurun-core/Cargo.toml
Normal 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
|
||||
229
crates/mirakurun-core/src/config.rs
Normal file
229
crates/mirakurun-core/src/config.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
509
crates/mirakurun-core/src/epg.rs
Normal file
509
crates/mirakurun-core/src/epg.rs
Normal 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(§ion, &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 = §ion[offset + 2..offset + 7];
|
||||
let duration = §ion[offset + 7..offset + 10];
|
||||
let descriptor_length =
|
||||
(usize::from(section[offset + 10] & 0x0f) << 8) | usize::from(section[offset + 11]);
|
||||
let Some(descriptor_end) = offset
|
||||
.checked_add(12)
|
||||
.and_then(|value| value.checked_add(descriptor_length))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if descriptor_end > end {
|
||||
return false;
|
||||
}
|
||||
if let Some(start_at) = decode_mjd_time(start) {
|
||||
let id = program_id(network_id, service_id, event_id);
|
||||
let program = programs.entry(id).or_insert_with(|| Program {
|
||||
id,
|
||||
event_id,
|
||||
service_id,
|
||||
network_id,
|
||||
start_at,
|
||||
duration: decode_bcd_duration(duration).unwrap_or(1),
|
||||
is_free: section[offset + 10] & 0x10 == 0,
|
||||
name: None,
|
||||
description: None,
|
||||
genres: None,
|
||||
video: None,
|
||||
audios: None,
|
||||
series: None,
|
||||
extended: None,
|
||||
related_items: None,
|
||||
});
|
||||
program.start_at = start_at;
|
||||
program.duration = decode_bcd_duration(duration).unwrap_or(1);
|
||||
program.is_free = section[offset + 10] & 0x10 == 0;
|
||||
parse_descriptors(§ion[offset + 12..descriptor_end], program);
|
||||
}
|
||||
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(§ion);
|
||||
section.extend_from_slice(&crc.to_be_bytes());
|
||||
|
||||
let mut packet = [0xff; PACKET_SIZE];
|
||||
packet[0] = 0x47;
|
||||
packet[1] = 0x40;
|
||||
packet[2] = 0x12;
|
||||
packet[3] = 0x10;
|
||||
packet[4] = 0;
|
||||
packet[5..5 + section.len()].copy_from_slice(§ion);
|
||||
|
||||
let mut collector = EitCollector::default();
|
||||
collector.push(&packet);
|
||||
let programs = collector.into_programs();
|
||||
assert_eq!(programs.len(), 1);
|
||||
assert_eq!(programs[0].name.as_deref(), Some("Test"));
|
||||
assert_eq!(programs[0].description.as_deref(), Some("OK"));
|
||||
assert_eq!(programs[0].duration, 3_600_000);
|
||||
assert!(programs[0].is_free);
|
||||
}
|
||||
}
|
||||
48
crates/mirakurun-core/src/error.rs
Normal file
48
crates/mirakurun-core/src/error.rs
Normal 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>;
|
||||
319
crates/mirakurun-core/src/filter.rs
Normal file
319
crates/mirakurun-core/src/filter.rs
Normal 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(§ion, self.service_id) {
|
||||
self.pmt_pid = Some(program.pmt_pid);
|
||||
self.allowed_pids.insert(program.pmt_pid);
|
||||
output.extend_from_slice(&build_pat_packet(
|
||||
§ion,
|
||||
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(§ion, 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(§ion[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(§ion[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(§ion).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(§ion);
|
||||
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,
|
||||
§ion_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,
|
||||
§ion_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(§ion);
|
||||
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
|
||||
}
|
||||
}
|
||||
15
crates/mirakurun-core/src/lib.rs
Normal file
15
crates/mirakurun-core/src/lib.rs
Normal 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};
|
||||
241
crates/mirakurun-core/src/persistence.rs
Normal file
241
crates/mirakurun-core/src/persistence.rs
Normal 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=");
|
||||
}
|
||||
}
|
||||
242
crates/mirakurun-core/src/service.rs
Normal file
242
crates/mirakurun-core/src/service.rs
Normal 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(§ion, &self.channel) else {
|
||||
continue;
|
||||
};
|
||||
self.last_section = Some(parsed.last_section);
|
||||
self.sections_seen
|
||||
.insert((parsed.version, parsed.section_number));
|
||||
for service in parsed.services {
|
||||
self.services.insert(service.id, service);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.last_section.is_some_and(|last| {
|
||||
(0..=last).all(|section| {
|
||||
self.sections_seen
|
||||
.iter()
|
||||
.any(|(_, seen_section)| *seen_section == section)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[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(§ion[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(§ion);
|
||||
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(§ion);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
220
crates/mirakurun-core/src/ts.rs
Normal file
220
crates/mirakurun-core/src/ts.rs
Normal 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(§ion), 0xe8f9_5e7d);
|
||||
}
|
||||
}
|
||||
846
crates/mirakurun-core/src/tuner.rs
Normal file
846
crates/mirakurun-core/src/tuner.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
43
crates/mirakurun-rs/Cargo.toml
Normal file
43
crates/mirakurun-rs/Cargo.toml
Normal 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
|
||||
19
crates/mirakurun-rs/build.rs
Normal file
19
crates/mirakurun-rs/build.rs
Normal 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}");
|
||||
}
|
||||
177
crates/mirakurun-rs/src/access.rs
Normal file
177
crates/mirakurun-rs/src/access.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
1700
crates/mirakurun-rs/src/api.rs
Normal file
1700
crates/mirakurun-rs/src/api.rs
Normal file
File diff suppressed because it is too large
Load Diff
94
crates/mirakurun-rs/src/assets.rs
Normal file
94
crates/mirakurun-rs/src/assets.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
102
crates/mirakurun-rs/src/cli.rs
Normal file
102
crates/mirakurun-rs/src/cli.rs
Normal 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()
|
||||
}
|
||||
147
crates/mirakurun-rs/src/commands.rs
Normal file
147
crates/mirakurun-rs/src/commands.rs
Normal 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(§ion).to_be_bytes());
|
||||
let mut packet = [0xff; PACKET_SIZE];
|
||||
packet[0] = 0x47;
|
||||
packet[1] = 0x40;
|
||||
packet[2] = 0x12;
|
||||
packet[3] = 0x10;
|
||||
packet[4] = 0;
|
||||
packet[5..5 + section.len()].copy_from_slice(§ion);
|
||||
|
||||
let 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"));
|
||||
}
|
||||
}
|
||||
786
crates/mirakurun-rs/src/jobs.rs
Normal file
786
crates/mirakurun-rs/src/jobs.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
37
crates/mirakurun-rs/src/main.rs
Normal file
37
crates/mirakurun-rs/src/main.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
219
crates/mirakurun-rs/src/rpc.rs
Normal file
219
crates/mirakurun-rs/src/rpc.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
462
crates/mirakurun-rs/src/scan.rs
Normal file
462
crates/mirakurun-rs/src/scan.rs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
172
crates/mirakurun-rs/src/server.rs
Normal file
172
crates/mirakurun-rs/src/server.rs
Normal 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>) {}
|
||||
284
crates/mirakurun-rs/src/state.rs
Normal file
284
crates/mirakurun-rs/src/state.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
13
crates/mirakurun-types/Cargo.toml
Normal file
13
crates/mirakurun-types/Cargo.toml
Normal 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
|
||||
808
crates/mirakurun-types/src/lib.rs
Normal file
808
crates/mirakurun-types/src/lib.rs
Normal 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
31
dist/mirakurun-rs.service
vendored
Normal 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
54
doc/installation.md
Normal 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
19
doc/migration.md
Normal 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
4
rust-toolchain.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.85.0"
|
||||
components = ["clippy", "rustfmt"]
|
||||
profile = "minimal"
|
||||
3335
web/package-lock.json
generated
Normal file
3335
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
web/package.json
Normal file
44
web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
web/src/components/DateTimeRange.sass
Normal file
33
web/src/components/DateTimeRange.sass
Normal 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
|
||||
105
web/src/components/DateTimeRange.tsx
Normal file
105
web/src/components/DateTimeRange.tsx
Normal 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")}
|
||||
–
|
||||
{end && <>
|
||||
{endDate.toFormat("HH:mm")}
|
||||
({durationS / 60}分間)
|
||||
</>}
|
||||
|
||||
<span className="relative">{relative}</span>
|
||||
|
||||
{progress && (
|
||||
<span className="progress">
|
||||
<span style={{ width: `${progress * 100}%` }} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
333
web/src/components/EPGTable.sass
Normal file
333
web/src/components/EPGTable.sass
Normal 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
|
||||
654
web/src/components/EPGTable.tsx
Normal file
654
web/src/components/EPGTable.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
82
web/src/components/Nav.sass
Normal file
82
web/src/components/Nav.sass
Normal 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
169
web/src/components/Nav.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
web/src/components/ProgramAVInfo.sass
Normal file
23
web/src/components/ProgramAVInfo.sass
Normal 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
|
||||
63
web/src/components/ProgramAVInfo.tsx
Normal file
63
web/src/components/ProgramAVInfo.tsx
Normal 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}: </>}
|
||||
{mode}
|
||||
{lang !== "日本語" && <> / {lang}</>}
|
||||
</span>
|
||||
);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="component-program-av-info">
|
||||
{labels}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
44
web/src/components/ProgramCardBase.sass
Normal file
44
web/src/components/ProgramCardBase.sass
Normal 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%
|
||||
120
web/src/components/ProgramCardBase.tsx
Normal file
120
web/src/components/ProgramCardBase.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
20
web/src/components/ProgramGenres.sass
Normal file
20
web/src/components/ProgramGenres.sass
Normal 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
|
||||
60
web/src/components/ProgramGenres.tsx
Normal file
60
web/src/components/ProgramGenres.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
4
web/src/components/ProgramPopover.sass
Normal file
4
web/src/components/ProgramPopover.sass
Normal file
@@ -0,0 +1,4 @@
|
||||
.component-program-popover
|
||||
padding: 15px
|
||||
min-width: 300px
|
||||
max-width: 380px
|
||||
70
web/src/components/ProgramPopover.tsx
Normal file
70
web/src/components/ProgramPopover.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
16
web/src/components/ProgramRelatedLinks.sass
Normal file
16
web/src/components/ProgramRelatedLinks.sass
Normal 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
|
||||
104
web/src/components/ProgramRelatedLinks.tsx
Normal file
104
web/src/components/ProgramRelatedLinks.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
38
web/src/components/ProgramTitle.sass
Normal file
38
web/src/components/ProgramTitle.sass
Normal 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
|
||||
101
web/src/components/ProgramTitle.tsx
Normal file
101
web/src/components/ProgramTitle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
50
web/src/components/Restart.tsx
Normal file
50
web/src/components/Restart.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
8
web/src/components/ServiceLink.sass
Normal file
8
web/src/components/ServiceLink.sass
Normal file
@@ -0,0 +1,8 @@
|
||||
.component-service-link
|
||||
display: flex
|
||||
gap: 10px
|
||||
align-items: center
|
||||
|
||||
> img
|
||||
max-height: 18px
|
||||
border-radius: 1px
|
||||
73
web/src/components/ServiceLink.tsx
Normal file
73
web/src/components/ServiceLink.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
127
web/src/components/VersionStatus.tsx
Normal file
127
web/src/components/VersionStatus.tsx
Normal 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>;
|
||||
};
|
||||
126
web/src/components/WatchButton.tsx
Normal file
126
web/src/components/WatchButton.tsx
Normal 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
4
web/src/custom.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.svg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
97
web/src/hooks/useWebStorageState.ts
Normal file
97
web/src/hooks/useWebStorageState.ts
Normal 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
50
web/src/icon-active.svg
Normal 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
45
web/src/icon-gray.svg
Normal 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
45
web/src/icon.svg
Normal 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
15
web/src/index.html
Normal 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
217
web/src/index.sass
Normal 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
101
web/src/index.tsx
Normal 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
63
web/src/modules/at.ts
Normal 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
119
web/src/modules/common.ts
Normal 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);
|
||||
}
|
||||
292
web/src/modules/constants.ts
Normal file
292
web/src/modules/constants.ts
Normal 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
35
web/src/modules/regexp.ts
Normal 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]+)|(https?://[\uFF01-\uFF5E]+)|(www\.[\x21-\x7e]+))/gi;
|
||||
|
||||
/** EPG オートリンク用 */
|
||||
export const epgXLinkFormat = /(?:Twitter|Twitter|X|X)[\s\S]{0,14}([@@][\da-z_]+)/gi
|
||||
|
||||
/** EPG オートリンク用 */
|
||||
export const epgInstagramLinkFormat = /(?:Instagram|Instagram|インスタグラム)[\s\S]{0,14}([@@][\da-z_]+)/gi
|
||||
427
web/src/modules/state.ts
Normal file
427
web/src/modules/state.ts
Normal 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
80
web/src/modules/ui.ts
Normal 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
37
web/src/redoc-ui.html
Normal 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>
|
||||
108
web/src/routes/AboutView.sass
Normal file
108
web/src/routes/AboutView.sass
Normal 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
|
||||
|
||||
188
web/src/routes/AboutView.tsx
Normal file
188
web/src/routes/AboutView.tsx
Normal 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 © 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>
|
||||
);
|
||||
};
|
||||
78
web/src/routes/ChannelsConfigView.sass
Normal file
78
web/src/routes/ChannelsConfigView.sass
Normal 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
|
||||
915
web/src/routes/ChannelsConfigView.tsx
Normal file
915
web/src/routes/ChannelsConfigView.tsx
Normal 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
198
web/src/routes/EPGView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
238
web/src/routes/HomeView.sass
Normal file
238
web/src/routes/HomeView.sass
Normal 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
583
web/src/routes/HomeView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
48
web/src/routes/JobsView.sass
Normal file
48
web/src/routes/JobsView.sass
Normal 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
510
web/src/routes/JobsView.tsx
Normal 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";
|
||||
}
|
||||
44
web/src/routes/LogsView.sass
Normal file
44
web/src/routes/LogsView.sass
Normal 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
104
web/src/routes/LogsView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
42
web/src/routes/ProgramView.sass
Normal file
42
web/src/routes/ProgramView.sass
Normal 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
|
||||
209
web/src/routes/ProgramView.tsx
Normal file
209
web/src/routes/ProgramView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
21
web/src/routes/SearchView.sass
Normal file
21
web/src/routes/SearchView.sass
Normal 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
|
||||
160
web/src/routes/SearchView.tsx
Normal file
160
web/src/routes/SearchView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
web/src/routes/ServerConfigView.sass
Normal file
86
web/src/routes/ServerConfigView.sass
Normal 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
|
||||
|
||||
584
web/src/routes/ServerConfigView.tsx
Normal file
584
web/src/routes/ServerConfigView.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
61
web/src/routes/TunersConfigView.sass
Normal file
61
web/src/routes/TunersConfigView.sass
Normal 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
|
||||
431
web/src/routes/TunersConfigView.tsx
Normal file
431
web/src/routes/TunersConfigView.tsx
Normal 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
29
web/src/tsconfig.json
Normal 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
Reference in New Issue
Block a user